73 Commits

Author SHA1 Message Date
towfiqi
7c6c7fc3d1 chore(release): 0.3.4 2024-01-15 23:24:07 +06:00
towfiqi
cca9f95358 fix: fixes local SC data not being removed on deleting domain. 2024-01-15 23:19:57 +06:00
towfiqi
faa88c9254 feat: adds ability to add multiple domains at once. 2024-01-15 23:05:35 +06:00
towfiqi
8b0ee562cf test: Updates test for SingleKeyword component. 2024-01-13 20:18:04 +06:00
towfiqi
2f08bb3f62 docs: Adds docs for Google Search console related functions 2024-01-13 20:06:04 +06:00
towfiqi
897aa0b7d7 chore: removes mocks from production build. 2024-01-13 12:04:15 +06:00
towfiqi
e166b588aa fix: Resolves incorrect keyword average SC data values in Tracker 2024-01-13 11:58:16 +06:00
towfiqi
c897a52550 feat: Adds the ability to show/hide Keys & Passwords in Settings Panel 2024-01-13 11:31:40 +06:00
towfiqi
df3a738788 fix: resolves newly added Domain's Update time rendering issue 2024-01-13 10:25:10 +06:00
towfiqi
4a47cedad8 refactor: improves Performance & Code Readability
- Replaces useEffet with useMemo & useLayoutEffect where necessary.
- Converts some useEffects to separate hooks.
- Moves the functions defined within components to utils.
- Splits the Keywords renderPosition function to its own component.
2024-01-13 10:10:49 +06:00
towfiqi
2783de5c65 refactor: separates client and backend utils. 2024-01-12 22:40:28 +06:00
towfiqi
08f44911d7 chore(release): 0.3.3 2023-11-12 20:59:08 +06:00
towfiqi
9dce1d5b48 chore: resolves test issues 2023-11-12 20:58:03 +06:00
towfiqi
fbd23ede25 feat: Shows total keywords count in domains page 2023-11-11 12:48:31 +06:00
towfiqi
60c68bd339 feat: Adds ability to visit pages from Insight tab 2023-11-11 11:42:14 +06:00
towfiqi
2339e31af9 feat: Domains now show their favicon.
closes #130
2023-11-11 11:29:59 +06:00
towfiqi
4a60271cac fix: Resolves Website Thumbnail missing issue
You can now set your own thum.io key to fetch screenshot. More in docs.

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

Fix keyword.id & authorization for searchapi
2023-11-09 14:50:11 +02:00
towfiqi
f164b287be chore(release): 0.3.1 2023-11-04 22:05:59 +06:00
towfiqi
97dd0b131b fix: Updates vulnerable dependencies 2023-11-04 22:05:10 +06:00
towfiqi
454454a422 fix: Removes dev files from docker volumes 2023-11-04 11:25:56 +06:00
towfiqi
4620f11c4b Fixed: Resolves missing Scrapers list on new install 2023-11-04 10:47:22 +06:00
towfiqi
7ab435ed8b chore(release): 0.3.0 2023-11-03 22:24:46 +06:00
towfiqi
9feff13f18 fix: Fxies special character keyword scrape issue.
closes #113 #122
2023-11-03 21:44:23 +06:00
towfiqi
f57bca23da fix: Resolves missing keyword scrape spinner issue 2023-11-03 21:30:00 +06:00
towfiqi
392122a710 fix: Fixes the weekly cron day issue.
closes #118
2023-11-03 21:05:28 +06:00
towfiqi
fc183d246d feat: Displays the Best position of the keywords
closes #89
2023-11-03 20:45:36 +06:00
towfiqi
994afbcedb chore: changes toggle button ui 2023-11-03 13:51:50 +06:00
towfiqi
d3d336fa71 feat: Remembers last selected coutry
closes #101
2023-11-03 12:47:29 +06:00
towfiqi
6f34d64fd5 chore: opens keyword detail view on chart click 2023-11-03 12:43:56 +06:00
towfiqi
a0014c7650 chore: breakup Settings component 2023-11-03 12:20:02 +06:00
towfiqi
dc3c7a722b feat: Adds ability to disable/clear retry queue for failed keywords 2023-11-03 11:59:51 +06:00
towfiqi
8a949ce4c0 fix: Cron stopped on failing to parse failed queue
closes #116
2023-11-03 11:58:17 +06:00
towfiqi
312d12f589 chore: fixes tag menu overlap issue. 2023-11-02 22:46:55 +06:00
towfiqi
be80ed7ef3 fix: Fixes import order error in some instances.
closes #114
2023-11-02 22:35:25 +06:00
towfiqi
4748ffc382 feat: Adds ability to search w/o case sensitivity
closes #115
2023-11-02 22:21:07 +06:00
towfiqi
c0470cfa9d fix: Fixes issue with adding hyphenated subdomains. 2023-11-02 22:15:26 +06:00
towfiqi
1d6b2be95a feat: Refresh All feature now shows update real-time 2023-11-02 21:51:06 +06:00
towfiqi
0d846b29f1 chore(release): 0.2.6 2023-03-29 21:01:13 +06:00
towfiqi
3b96dab9cc chore: Adds Space Serp Details. 2023-03-29 20:54:55 +06:00
towfiqi
0a83924ffe feat: Add option to Delay Between scrapes.
fixes #87
2023-03-29 20:54:30 +06:00
towfiqi
d9505158c4 fix: Fixes first Keryword Error cut off issue. 2023-03-29 20:11:56 +06:00
towfiqi
9757fde02e fix: Fixes lags when tracking thousands of keywords
fixes #88
2023-03-29 12:59:22 +06:00
towfiqi
0538a8c016 feat: Integrates Space Serp. 2023-03-29 12:14:28 +06:00
Towfiq I
cace34f39a Merge pull request #85 from Teeth-Talk/hotfix-typo
fix(components): fix typo "Goolge" -> "Google"
2023-03-29 10:49:04 +06:00
Martin Silha
dce7c412e8 fix(components): fix typo "Goolge" -> "Google" 2023-03-16 21:44:59 +00:00
towfiqi
e61dfb5b90 chore(release): 0.2.5 2023-03-07 11:08:42 +06:00
towfiqi
b9d58a721d fix: Settings Update Toast was not showing up. 2023-03-05 19:39:14 +06:00
towfiqi
b83df5f3db feat: Adds current App version Number in Footer. 2023-03-05 19:23:07 +06:00
towfiqi
3b6d034d6f feat: Adds Keyword Scraping Interval Settings.
fixes #81, #76
2023-03-05 12:28:21 +06:00
towfiqi
5dd366b91e fix: Fixes Broken Image thumbnail loading issue. 2023-03-05 12:14:08 +06:00
towfiqi
5fc1779783 chore(release): 0.2.4 2023-02-15 23:33:37 +06:00
towfiqi
c5af94a146 feat: Keyword ranking pages can now be clicked. 2023-02-15 23:33:23 +06:00
towfiqi
c406588953 fix: Fixes broken Login on windows
fixes: 77, 80
2023-02-15 23:32:38 +06:00
towfiqi
3c48d130b6 fix: Fixes Node Cron memory leak issue. 2023-02-15 23:30:16 +06:00
towfiqi
99dbbd1dd9 chore(release): 0.2.3 2023-01-12 22:01:40 +06:00
towfiqi
acc0b39d80 fix: ScrapingAnt Mobile Keyword Scrape not working 2023-01-12 12:26:54 +06:00
towfiqi
a1108d240e fix: Mobile Keyword Scraping not working.
fixes #58
2023-01-12 12:25:42 +06:00
towfiqi
d9e0d0107a chore: keyword details title style issue fix. 2023-01-11 21:30:34 +06:00
towfiqi
9e9dad7631 feat: Ability to tag multiple keywords at once
fixes #54
2023-01-11 21:29:52 +06:00
towfiqi
8139e399c1 fix: Fixes Position and View Sort.
fixes #46
2023-01-11 13:15:09 +06:00
towfiqi
cb24696a1f fix: Fixes wrong CTR value for Search Console Data
fixes #48
2023-01-11 12:42:27 +06:00
towfiqi
b50733defc feat: Set USERNAME as well as USER variable
Mac Users weren't able to set USER variable for the project.
2023-01-11 12:29:29 +06:00
Towfiq
0c8b457eee chore(release): 0.2.2 2022-12-25 15:59:12 +06:00
Towfiq
123ad81dae fix: Fixes bug that prevents Saving API settings
fixes: issue #45
2022-12-25 15:58:44 +06:00
Towfiq
6c48b87e5e chore(release): 0.2.1 2022-12-24 20:28:07 +06:00
Towfiq
cf8b2c6913 refactor: Scraper 2022-12-24 20:27:55 +06:00
Towfiq
6d6e2f63d0 fixes: Broken Proxy Scraper
fixes #33
2022-12-24 08:29:44 +06:00
Towfiq
74c9603293 fixes: Wrong position change indicator when >100 2022-12-24 08:28:09 +06:00
91 changed files with 5866 additions and 12517 deletions

View File

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

View File

@@ -2,6 +2,138 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.3.4](https://github.com/towfiqi/serpbear/compare/v0.3.3...v0.3.4) (2024-01-15)
### Features
* adds ability to add multiple domains at once. ([faa88c9](https://github.com/towfiqi/serpbear/commit/faa88c92542194f19b5cfe2b5cfd07d7d4f7ee46))
* Adds the ability to show/hide Keys & Passwords in Settings Panel ([c897a52](https://github.com/towfiqi/serpbear/commit/c897a525509baf5b9e8df18d82f5e87aec64f66e))
### Bug Fixes
* fixes local SC data not being removed on deleting domain. ([cca9f95](https://github.com/towfiqi/serpbear/commit/cca9f95358b2d3ea06edb33595cdbf616a175469))
* Resolves incorrect keyword average SC data values in Tracker ([e166b58](https://github.com/towfiqi/serpbear/commit/e166b588aa6c8db55d61b5bc13db66514575c745))
* resolves newly added Domain's Update time rendering issue ([df3a738](https://github.com/towfiqi/serpbear/commit/df3a738788fa957e7246a0feefe395a9eadd5baf))
### [0.3.3](https://github.com/towfiqi/serpbear/compare/v0.3.2...v0.3.3) (2023-11-12)
### Features
* Adds ability to visit pages from Insight tab ([60c68bd](https://github.com/towfiqi/serpbear/commit/60c68bd339db7aeed35aea035dd21691702ffee3))
* Domains now show their favicon. ([2339e31](https://github.com/towfiqi/serpbear/commit/2339e31af9e90bf918f5bcd4f23114f38cef0313)), closes [#130](https://github.com/towfiqi/serpbear/issues/130)
* Shows total keywords count in domains page ([fbd23ed](https://github.com/towfiqi/serpbear/commit/fbd23ede256062c72ec2f7e3983a0a02f0240725))
### Bug Fixes
* Resolves Website Thumbnail missing issue ([4a60271](https://github.com/towfiqi/serpbear/commit/4a60271cac1209dc02748c4d31943bb21c9ecaf2)), closes [#131](https://github.com/towfiqi/serpbear/issues/131)
### [0.3.2](https://github.com/towfiqi/serpbear/compare/v0.3.1...v0.3.2) (2023-11-09)
### Bug Fixes
* Resolves issue with adding long tld emails ([9b9b74a](https://github.com/towfiqi/serpbear/commit/9b9b74af4c249e27458d29ba052e96ab2db8b640)), closes [#127](https://github.com/towfiqi/serpbear/issues/127)
### [0.3.1](https://github.com/towfiqi/serpbear/compare/v0.3.0...v0.3.1) (2023-11-04)
### Bug Fixes
* Removes dev files from docker volumes ([454454a](https://github.com/towfiqi/serpbear/commit/454454a422bab4d37a2d43ad95868e293a97b88e))
* Updates vulnerable dependencies ([97dd0b1](https://github.com/towfiqi/serpbear/commit/97dd0b131be4cec73d07f35062334dd1881f0013))
## [0.3.0](https://github.com/towfiqi/serpbear/compare/v0.2.6...v0.3.0) (2023-11-03)
### Features
* Adds ability to disable/clear retry queue for failed keywords ([dc3c7a7](https://github.com/towfiqi/serpbear/commit/dc3c7a722b18248115969c51f2495ccf1c43926d))
* Adds ability to search w/o case sensitivity ([4748ffc](https://github.com/towfiqi/serpbear/commit/4748ffc382161c5d861b8d43e8eba466a031e2bc)), closes [#115](https://github.com/towfiqi/serpbear/issues/115)
* Displays the Best position of the keywords ([fc183d2](https://github.com/towfiqi/serpbear/commit/fc183d246d55e0eecf43c91f6da8a59192e8e771)), closes [#89](https://github.com/towfiqi/serpbear/issues/89)
* Refresh All feature now shows update real-time ([1d6b2be](https://github.com/towfiqi/serpbear/commit/1d6b2be95aa133b7998f5cf098f15aa32f5badd2))
* Remembers last selected coutry ([d3d336f](https://github.com/towfiqi/serpbear/commit/d3d336fa71cc789624b10f3cdd1a2b5983053e6f)), closes [#101](https://github.com/towfiqi/serpbear/issues/101)
### Bug Fixes
* Resolves missing keyword scrape spinner issue ([f57bca2](https://github.com/towfiqi/serpbear/commit/f57bca23daa3fe888af4c19a681dcec6b6100d83))
* Cron stopped on failing to parse failed queue ([8a949ce](https://github.com/towfiqi/serpbear/commit/8a949ce4c078ff377e91a95c4b86ef2b15dae88b)), closes [#116](https://github.com/towfiqi/serpbear/issues/116)
* Fixes import order error in some instances. ([be80ed7](https://github.com/towfiqi/serpbear/commit/be80ed7ef3dd0a315c5ad67d17e61a4797dc274c)), closes [#114](https://github.com/towfiqi/serpbear/issues/114)
* Fixes issue with adding hyphenated subdomains. ([c0470cf](https://github.com/towfiqi/serpbear/commit/c0470cfa9d0dac86317c886065b461cfe82ffb16))
* Fixes the weekly cron day issue. ([392122a](https://github.com/towfiqi/serpbear/commit/392122a7101683342830e900c6f0c39f9272bb34)), closes [#118](https://github.com/towfiqi/serpbear/issues/118)
* Fxies special character keyword scrape issue. ([9feff13](https://github.com/towfiqi/serpbear/commit/9feff13f18a4d72203dde694a147831f990b37fb)), closes [#113](https://github.com/towfiqi/serpbear/issues/113) [#122](https://github.com/towfiqi/serpbear/issues/122)
### [0.2.6](https://github.com/towfiqi/serpbear/compare/v0.2.5...v0.2.6) (2023-03-29)
### Features
* Add option to Delay Between scrapes. ([0a83924](https://github.com/towfiqi/serpbear/commit/0a83924ffe2243c52849c167c6c15d9688ff1dc7)), closes [#87](https://github.com/towfiqi/serpbear/issues/87)
* Integrates Space Serp. ([0538a8c](https://github.com/towfiqi/serpbear/commit/0538a8c01601d2f6365848580591a248528e67c7))
### Bug Fixes
* **components:** fix typo "Goolge" -> "Google" ([dce7c41](https://github.com/towfiqi/serpbear/commit/dce7c412e813fc845973f36ad1c9fa91df4a6611))
* Fixes first Keryword Error cut off issue. ([d950515](https://github.com/towfiqi/serpbear/commit/d9505158c439a924a1c86eb8243faf2a15bed43e))
* Fixes lags when tracking thousands of keywords ([9757fde](https://github.com/towfiqi/serpbear/commit/9757fde02ec83405546733381104c54ed6510681)), closes [#88](https://github.com/towfiqi/serpbear/issues/88)
### [0.2.5](https://github.com/towfiqi/serpbear/compare/v0.2.4...v0.2.5) (2023-03-07)
### Features
* Adds current App version Number in Footer. ([b83df5f](https://github.com/towfiqi/serpbear/commit/b83df5f3dbd64db657d31f0526438e7165e1b475))
* Adds Keyword Scraping Interval Settings. ([3b6d034](https://github.com/towfiqi/serpbear/commit/3b6d034d6f7da0b4259070220fffff44184dd680)), closes [#81](https://github.com/towfiqi/serpbear/issues/81) [#76](https://github.com/towfiqi/serpbear/issues/76)
### Bug Fixes
* Fixes Broken Image thumbnail loading issue. ([5dd366b](https://github.com/towfiqi/serpbear/commit/5dd366b91e2a94e658bf5250a8a0fa64c09e1c11))
* Settings Update Toast was not showing up. ([b9d58a7](https://github.com/towfiqi/serpbear/commit/b9d58a721df12f3f34220a3ae5da6897e23c83ec))
### [0.2.4](https://github.com/towfiqi/serpbear/compare/v0.2.3...v0.2.4) (2023-02-15)
### Features
* Keyword ranking pages can now be clicked. ([c5af94a](https://github.com/towfiqi/serpbear/commit/c5af94a1469713ed4092253d26953ee0ed28c25d))
### Bug Fixes
* Fixes broken Login on windows ([c406588](https://github.com/towfiqi/serpbear/commit/c406588953035e4177a64011c13eb0e3aedffe89))
* Fixes Node Cron memory leak issue. ([3c48d13](https://github.com/towfiqi/serpbear/commit/3c48d130b6f229a4ac27ec43ef1ea3a6640cecf6))
### [0.2.3](https://github.com/towfiqi/serpbear/compare/v0.2.2...v0.2.3) (2023-01-12)
### Features
* Ability to tag multiple keywords at once ([9e9dad7](https://github.com/towfiqi/serpbear/commit/9e9dad7631691b2a836fdd4c522b1f933b17e285)), closes [#54](https://github.com/towfiqi/serpbear/issues/54)
* Set USERNAME as well as USER variable ([b50733d](https://github.com/towfiqi/serpbear/commit/b50733defc2c06e0f92ca3e88fd1f74684eee9c0))
### Bug Fixes
* Fixes Position and View Sort. ([8139e39](https://github.com/towfiqi/serpbear/commit/8139e399c13ab8be767facef9a19c67dec06ed64)), closes [#46](https://github.com/towfiqi/serpbear/issues/46)
* Fixes wrong CTR value for Search Console Data ([cb24696](https://github.com/towfiqi/serpbear/commit/cb24696a1f47b02a11c68cd1c673ea8b1bacd144)), closes [#48](https://github.com/towfiqi/serpbear/issues/48)
* Mobile Keyword Scraping not working. ([a1108d2](https://github.com/towfiqi/serpbear/commit/a1108d240ea38ab0886ef3722b0c937ec5a45591)), closes [#58](https://github.com/towfiqi/serpbear/issues/58)
* ScrapingAnt Mobile Keyword Scrape not working ([acc0b39](https://github.com/towfiqi/serpbear/commit/acc0b39d80d4f9371967a0d425ed205c5d866eea))
### [0.2.2](https://github.com/towfiqi/serpbear/compare/v0.2.1...v0.2.2) (2022-12-25)
### Bug Fixes
* Fixes bug that prevents Saving API settings ([123ad81](https://github.com/towfiqi/serpbear/commit/123ad81dae10aa28848148d0f3da5cf1f7de7c57)), closes [#45](https://github.com/towfiqi/serpbear/issues/45)
### [0.2.1](https://github.com/towfiqi/serpbear/compare/v0.2.0...v0.2.1) (2022-12-24)
## [0.2.0](https://github.com/towfiqi/serpbear/compare/v0.1.7...v0.2.0) (2022-12-21)

View File

@@ -10,6 +10,9 @@ COPY . .
FROM node:lts-alpine AS builder
WORKDIR /app
COPY --from=deps /app ./
RUN rm -rf /app/data
RUN rm -rf /app/__tests__
RUN rm -rf /app/__mocks__
RUN npm run build
@@ -29,7 +32,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./
COPY --from=builder --chown=nextjs:nodejs /app/email ./email
RUN rm package.json
RUN npm init -y
RUN npm i cryptr dotenv node-cron @googleapis/searchconsole
RUN npm i cryptr dotenv croner @googleapis/searchconsole
RUN npm i -g concurrently
USER nextjs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ const ChartSlim = ({ labels, sreies }:ChartProps) => {
},
};
return <div className='w-[120px] h-[30px] rounded border border-gray-200'>
return <div className='w-[100px] h-[30px] rounded border border-gray-200'>
<Line
datasetIdKey='XXX'
options={options}

View File

@@ -221,6 +221,14 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
</g>
</svg>
}
{type === 'eye-closed'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<g fill="none" stroke={color} strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M6.873 17.129c-1.845-1.31-3.305-3.014-4.13-4.09a1.693 1.693 0 0 1 0-2.077C4.236 9.013 7.818 5 12 5c1.876 0 3.63.807 5.13 1.874"/>
<path d="M14.13 9.887a3 3 0 1 0-4.243 4.242M4 20L20 4M10 18.704A7.124 7.124 0 0 0 12 19c4.182 0 7.764-4.013 9.257-5.962a1.694 1.694 0 0 0-.001-2.078A22.939 22.939 0 0 0 19.57 9"/>
</g>
</svg>
}
{type === 'target'
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
<path d="M19.938 13A8.004 8.004 0 0 1 13 19.938V22h-2v-2.062A8.004 8.004 0 0 1 4.062 13H2v-2h2.062A8.004 8.004 0 0 1 11 4.062V2h2v2.062A8.004 8.004 0 0 1 19.938 11H22v2h-2.062zM12 18a6 6 0 1 0 0-12a6 6 0 0 0 0 12zm0-3a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" fill={color} fillRule="nonzero"/>

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React from 'react';
import Icon from './Icon';
import useOnKey from '../../hooks/useOnKey';
type ModalProps = {
children: React.ReactNode,
@@ -9,17 +10,7 @@ type ModalProps = {
}
const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => {
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeModal]);
useOnKey('Escape', closeModal);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import Icon from './Icon';
type SecretFieldProps = {
label: string;
value: string;
onChange: Function;
placeholder?: string;
classNames?: string;
hasError?: boolean;
}
const SecretField = ({ label = '', value = '', placeholder = '', onChange, hasError = false }: SecretFieldProps) => {
const [showValue, setShowValue] = useState(false);
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div className="settings__section__secret mb-5 relative">
<label className={labelStyle}>{label}</label>
<span
className="absolute top-8 right-0 px-2 py-1 cursor-pointer text-gray-400 select-none"
onClick={() => setShowValue(!showValue)}>
<Icon type={showValue ? 'eye-closed' : 'eye'} size={18} />
</span>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none
focus:border-blue-200 ${hasError ? ' border-red-400 focus:border-red-400' : ''} `}
type={showValue ? 'text' : 'password'}
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
placeholder={placeholder}
/>
</div>
);
};
export default SecretField;

View File

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

View File

@@ -1,53 +1,60 @@
import React, { useState } from 'react';
import Modal from '../common/Modal';
import { useAddDomain } from '../../services/domains';
import { isValidDomain } from '../../utils/client/validators';
type AddDomainProps = {
domains: DomainType[],
closeModal: Function
}
const AddDomain = ({ closeModal }: AddDomainProps) => {
const AddDomain = ({ closeModal, domains = [] }: AddDomainProps) => {
const [newDomain, setNewDomain] = useState<string>('');
const [newDomainError, setNewDomainError] = useState<boolean>(false);
const [newDomainError, setNewDomainError] = useState('');
const { mutate: addMutate, isLoading: isAdding } = useAddDomain(() => closeModal());
const addDomain = () => {
// console.log('ADD NEW DOMAIN', newDomain);
if (/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/.test(newDomain.trim())) {
setNewDomainError(false);
setNewDomainError('');
const existingDomains = domains.map((d) => d.domain);
const insertedDomains = newDomain.split('\n');
const domainsTobeAdded:string[] = [];
const invalidDomains:string[] = [];
insertedDomains.forEach((dom) => {
const domain = dom.trim();
if (isValidDomain(domain)) {
if (!existingDomains.includes(domain)) {
domainsTobeAdded.push(domain);
}
} else {
invalidDomains.push(domain);
}
});
if (invalidDomains.length > 0) {
setNewDomainError(`Please Insert Valid Domain names. Invalid Domains: ${invalidDomains.join(', ')}`);
} else if (domainsTobeAdded.length > 0) {
// TODO: Domain Action
addMutate(newDomain.trim());
} else {
setNewDomainError(true);
addMutate(domainsTobeAdded);
}
};
const handleDomainInput = (e:React.FormEvent<HTMLInputElement>) => {
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); }
const handleDomainInput = (e:React.ChangeEvent<HTMLTextAreaElement>) => {
if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(''); }
setNewDomain(e.currentTarget.value);
};
return (
<Modal closeModal={() => { closeModal(false); }} title={'Add New Domain'}>
<div data-testid="adddomain_modal">
<h4 className='text-sm mt-4'>
Domain Name {newDomainError && <span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>Not a Valid Domain</span>}
</h4>
<input
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
${newDomainError ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
<h4 className='text-sm mt-4'>Domain Names</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 Domains here. Insert Each Domain in a New line."
value={newDomain}
placeholder={'example.com'}
onChange={handleDomainInput}
autoFocus={true}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addDomain();
}
}}
/>
onChange={handleDomainInput}>
</textarea>
{newDomainError && <div><span className=' ml-2 block float-right text-red-500 text-xs font-semibold'>{newDomainError}</span></div>}
<div className='mt-6 text-right text-sm font-semibold'>
<button className='py-2 px-5 rounded cursor-pointer bg-indigo-50 text-slate-500 mr-3' onClick={() => closeModal(false)}>Cancel</button>
<button className='py-2 px-5 rounded cursor-pointer bg-blue-700 text-white' onClick={() => !isAdding && addDomain() }>

View File

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

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
@@ -18,7 +18,10 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
const router = useRouter();
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>({ notification_interval: 'never', notification_emails: '' });
const [domainSettings, setDomainSettings] = useState<DomainSettings>(() => ({
notification_interval: domain && domain.notification_interval ? domain.notification_interval : 'never',
notification_emails: domain && domain.notification_emails ? domain.notification_emails : '',
}));
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
const { mutate: deleteMutate } = useDeleteDomain(() => {
@@ -26,12 +29,6 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
router.push('/domains');
});
useEffect(() => {
if (domain) {
setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails });
}
}, [domain]);
const updateNotiEmails = (event:React.FormEvent<HTMLInputElement>) => {
setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value });
};
@@ -41,7 +38,7 @@ const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
let error: DomainSettingsError | null = null;
if (domainSettings.notification_emails) {
const notification_emails = domainSettings.notification_emails.split(',');
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(x) === false);
const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,15})+$/.test(x) === false);
console.log('invalidEmails: ', invalidEmails);
if (invalidEmails) {
error = { type: 'email', msg: 'Invalid Email' };

View File

@@ -12,7 +12,7 @@ type SCInsightProps = {
isConsoleIntegrated: boolean,
}
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => {
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
const [activeTab, setActiveTab] = useState<string>('stats');
const insightItems = insight[activeTab as keyof InsightDataType];
@@ -108,7 +108,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
(item:SCInsightItem, index: number) => {
const insightItemCount = insight ? insightItems : [];
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />;
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
},
)
}
@@ -117,7 +117,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
)}
{!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Goolge Search has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
Google Search has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
</p>
)}
</div>

View File

@@ -1,21 +1,22 @@
import React from 'react';
import countries from '../../utils/countries';
import Icon from '../common/Icon';
import { formattedNum } from '../../utils/client/helpers';
type InsightItemProps = {
item: SCInsightItem,
lastItem: boolean,
type: string
type: string,
domain: string
}
const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
let firstItem = keyword;
if (type === 'pages') { firstItem = page; } if (type === 'stats') {
firstItem = date && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(date));
}
if (type === 'countries') { firstItem = countries[country] && countries[country][0]; }
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
return (
<div
@@ -24,7 +25,7 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'>
{type === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
{firstItem}
{type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
</div>
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
@@ -34,7 +35,6 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
{Math.round(position)}
</div>
{/* <div className='keyword_imp text-center inline-block lg:flex-1'>{formattedNum(clicks)}</div> */}
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-14 p-2 text-base mt-[-55px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{formattedNum(clicks)}

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react';
import React, { useMemo } from 'react';
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { formattedNum } from '../../utils/client/helpers';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
@@ -12,21 +13,15 @@ type InsightStatsProps = {
}
const InsightStats = ({ stats = [], totalKeywords = 0, totalPages = 0 }:InsightStatsProps) => {
const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);
const [totalStat, setTotalStat] = useState({ impressions: 0, clicks: 0, ctr: 0, position: 0 });
useEffect(() => {
if (stats.length > 0) {
const totalStats = 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 });
setTotalStat(totalStats);
}
const totalStat = useMemo(() => {
return 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]);
const chartData = useMemo(() => {

View File

@@ -21,7 +21,8 @@ type KeywordsInput = {
const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
const [error, setError] = useState<string>('');
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: 'US', domain, tags: '' });
const defCountry = localStorage.getItem('default_country') || 'US';
const [newKeywordsData, setNewKeywordsData] = useState<KeywordsInput>({ keywords: '', device: 'desktop', country: defCountry, domain, tags: '' });
const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false));
const deviceTabStyle = 'cursor-pointer px-3 py-2 rounded mr-2';
@@ -64,7 +65,10 @@ const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => {
selected={[newKeywordsData.country]}
options={Object.keys(countries).map((countryISO:string) => { return { label: countries[countryISO][0], value: countryISO }; })}
defaultLabel='All Countries'
updateField={(updated:string[]) => setNewKeywordsData({ ...newKeywordsData, country: updated[0] })}
updateField={(updated:string[]) => {
setNewKeywordsData({ ...newKeywordsData, country: updated[0] });
localStorage.setItem('default_country', updated[0]);
}}
rounded='rounded'
maxHeight={48}
flags={true}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
type AddTagsProps = {
keywords: KeywordType[],
closeModal: Function
}
const AddTags = ({ keywords = [], closeModal }: AddTagsProps) => {
const [tagInput, setTagInput] = useState('');
const [inputError, setInputError] = useState('');
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const addTag = () => {
if (keywords.length === 0) { return; }
if (!tagInput) {
setInputError('Please Insert a Tag!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
const tagsArray = tagInput.split(',').map((t) => t.trim());
const tagsPayload:any = {};
keywords.forEach((keyword:KeywordType) => {
tagsPayload[keyword.ID] = [...keyword.tags, ...tagsArray];
});
updateMutate({ tags: tagsPayload });
};
return (
<Modal closeModal={() => { closeModal(false); }} title={`Add New Tags to ${keywords.length} Selected Keyword`}>
<div className="relative">
{inputError && <span className="absolute top-[-24px] text-red-400 text-sm font-semibold">{inputError}</span>}
<span className='absolute text-gray-400 top-3 left-2'><Icon type="tags" size={16} /></span>
<input
className='w-full border rounded border-gray-200 py-3 px-4 pl-8 outline-none focus:border-indigo-300'
placeholder='Insert Tags. eg: tag1, tag2'
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addTag();
}
}}
/>
<button
className=" absolute right-2 top-2 cursor-pointer rounded p-2 px-4 bg-indigo-600 text-white font-semibold text-sm"
onClick={addTag}>
Apply
</button>
</div>
</Modal>
);
};
export default AddTags;

View File

@@ -4,11 +4,13 @@ import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import ChartSlim from '../common/ChartSlim';
import { generateTheChartData } from '../common/generateChartData';
import KeywordPosition from './KeywordPosition';
import { generateTheChartData } from '../../utils/client/generateChartData';
type KeywordProps = {
keywordData: KeywordType,
selected: boolean,
index: number,
refreshkeyword: Function,
favoriteKeyword: Function,
removeKeyword: Function,
@@ -18,6 +20,7 @@ type KeywordProps = {
lastItem?:boolean,
showSCData: boolean,
scDataType: string,
style: Object
}
const Keyword = (props: KeywordProps) => {
@@ -32,6 +35,8 @@ const Keyword = (props: KeywordProps) => {
manageTags,
lastItem,
showSCData = true,
style,
index,
scDataType = 'threeDays',
} = props;
const {
@@ -56,27 +61,35 @@ const Keyword = (props: KeywordProps) => {
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const previousPos = historySorted[historySorted.length - 2].position;
status = previousPos === 0 ? position : previousPos - position;
if (position === 0 && previousPos > 0) {
status = previousPos - 100;
}
}
return status;
}, [history, position]);
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
const bestPosition: false | {position: number, date: string} = useMemo(() => {
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);
if (historyArray[0]) {
bestPos = { ...historyArray[0] };
}
}
const renderPosition = (pos:number, type?:string) => {
if (pos === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
if (updating && type !== 'sc') {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return pos;
};
return bestPos || false;
}, [history]);
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
return (
<div
key={keyword}
style={style}
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 lg:flex-1 lg:basis-20 lg:w-auto font-semibold cursor-pointer'>
<button
className={`p-0 mr-2 leading-[0px] inline-block rounded-sm pt-0 px-[1px] pb-[3px] border
@@ -97,22 +110,40 @@ const Keyword = (props: KeywordProps) => {
</button>
}
</div>
<div
className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-12 p-2 text-base mt-[-20px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition(position)}
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 font-semibold`}>
<KeywordPosition position={position} />
{!updating && positionChange > 0 && <i className=' not-italic ml-1 text-xs text-[#5ed7c3]'> {positionChange}</i>}
{!updating && positionChange < 0 && <i className=' not-italic ml-1 text-xs text-red-300'> {positionChange}</i>}
</div>
<div
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`}>
{bestPosition ? bestPosition.position || '-' : (position || '-')}
</div>
{chartData.labels.length > 0 && (
<div className='lg:flex-1 hidden lg:block'>
<div
className='hidden basis-32 grow-0 cursor-pointer lg:block'
onClick={() => showKeywordDetails()}>
<ChartSlim labels={chartData.labels} sreies={chartData.sreies} />
</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`}>
<span className='mr-3 lg:hidden'><Icon type="link-alt" size={14} color="#999" /></span>{turncatedURL || '-'}</div>
<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'>
<span className='mr-2 lg:hidden'><Icon type="clock" size={14} color="#999" /></span>
@@ -124,7 +155,10 @@ const Keyword = (props: KeywordProps) => {
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>
{renderPosition(keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0, 'sc')}
<KeywordPosition
position={keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0}
type='sc'
/>
</span>
<span className='min-w-[40px]'>
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}
@@ -165,8 +199,10 @@ const Keyword = (props: KeywordProps) => {
</ul>
)}
</div>
{lastUpdateError && lastUpdateError.date && showPositionError && (
<div className=' absolute mt-[-70px] p-2 bg-white z-30 border border-red-200 rounded w-[220px] left-4 shadow-sm text-xs lg:bottom-12'>
<div className={`absolute p-2 bg-white z-30 border border-red-200 rounded w-[220px] left-4 shadow-sm text-xs
${index > 2 ? 'lg:bottom-12 mt-[-70px]' : ' top-12'}`}>
Error Updating Keyword position (Tried <TimeAgo
title={dayjs(lastUpdateError.date).format('DD-MMM-YYYY, hh:mm:ss A')}
date={lastUpdateError.date} />)
@@ -177,7 +213,8 @@ const Keyword = (props: KeywordProps) => {
{lastUpdateError.scraper && <strong className='capitalize'>{lastUpdateError.scraper}: </strong>}{lastUpdateError.error}
</div>
</div>
)}
)}
</div>
);
};

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import dayjs from 'dayjs';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import Chart from '../common/Chart';
import SelectField from '../common/SelectField';
import { generateTheChartData } from '../common/generateChartData';
import { useFetchSingleKeyword } from '../../services/keywords';
import useOnKey from '../../hooks/useOnKey';
import { generateTheChartData } from '../../utils/client/generateChartData';
type KeywordDetailsProps = {
keyword: KeywordType,
@@ -13,11 +15,12 @@ type KeywordDetailsProps = {
const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
const updatedDate = new Date(keyword.lastUpdated);
const [keywordHistory, setKeywordHistory] = useState<KeywordHistory>(keyword.history);
const [keywordSearchResult, setKeywordSearchResult] = useState<KeywordLastResult[]>([]);
const [chartTime, setChartTime] = useState<string>('30');
const searchResultContainer = useRef<HTMLDivElement>(null);
const searchResultFound = useRef<HTMLDivElement>(null);
const { data: keywordData } = useFetchSingleKeyword(keyword.ID);
const keywordHistory: KeywordHistory = keywordData?.history || keyword.history;
const keywordSearchResult: KeywordLastResult = keywordData?.searchResult || keyword.history;
const dateOptions = [
{ label: 'Last 7 Days', value: '7' },
{ label: 'Last 30 Days', value: '30' },
@@ -26,39 +29,9 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
{ label: 'All Time', value: 'all' },
];
useEffect(() => {
const fetchFullKeyword = async () => {
try {
const fetchURL = `${window.location.origin}/api/keyword?id=${keyword.ID}`;
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
if (res.keyword) {
console.log(res.keyword, new Date().getTime());
setKeywordHistory(res.keyword.history || []);
setKeywordSearchResult(res.keyword.lastResult || []);
}
} catch (error) {
console.log(error);
}
};
if (keyword.lastResult.length === 0) {
fetchFullKeyword();
}
}, [keyword]);
useOnKey('Escape', closeDetails);
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeDetails();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeDetails]);
useEffect(() => {
useLayoutEffect(() => {
if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) {
searchResultFound.current.scrollIntoView({
behavior: 'smooth',
@@ -85,7 +58,7 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
<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 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 text-blue-700 text-xs font-bold'>{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

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useMemo } from 'react';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import countries from '../../utils/countries';
@@ -17,11 +17,6 @@ type KeywordFilterProps = {
SCcountries?: string[];
}
type KeywordCountState = {
desktop: number,
mobile: number
}
const KeywordFilters = (props: KeywordFilterProps) => {
const {
device,
@@ -36,20 +31,14 @@ const KeywordFilters = (props: KeywordFilterProps) => {
integratedConsole = false,
SCcountries = [],
} = props;
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
const [sortOptions, showSortOptions] = useState(false);
const [filterOptions, showFilterOptions] = useState(false);
useEffect(() => {
const keyWordCount = { desktop: 0, mobile: 0 };
keywords.forEach((k) => {
if (k.device === 'desktop') {
keyWordCount.desktop += 1;
} else {
keyWordCount.mobile += 1;
}
});
setKeywordCounts(keyWordCount);
const keywordCounts = useMemo(() => {
return keywords.reduce((acc, k) => ({
desktop: k.device === 'desktop' ? acc.desktop + 1 : acc.desktop,
mobile: k.device !== 'desktop' ? acc.mobile + 1 : acc.mobile,
}), { desktop: 0, mobile: 0 });
}, [keywords]);
const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs });
@@ -82,10 +71,10 @@ const KeywordFilters = (props: KeywordFilterProps) => {
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
];
if (integratedConsole) {
sortOptionChoices.push({ value: 'imp_asc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
sortOptionChoices.push({ value: 'imp_desc', label: 'Least Viewed' });
sortOptionChoices.push({ value: 'visits_asc', label: 'Most Visited' });
sortOptionChoices.push({ value: 'visits_desc', label: 'Least Visited' });
sortOptionChoices.push({ value: 'imp_desc', label: `Most Viewed${isConsole ? ' (Default)' : ''}` });
sortOptionChoices.push({ value: 'imp_asc', label: 'Least Viewed' });
sortOptionChoices.push({ value: 'visits_desc', label: 'Most Visited' });
sortOptionChoices.push({ value: 'visits_asc', label: 'Least Visited' });
}
if (isConsole) {
sortOptionChoices.splice(2, 2);

View File

@@ -0,0 +1,19 @@
import Icon from '../common/Icon';
type KeywordPositionProps = {
position: number,
updating?: boolean,
type?: string,
}
const KeywordPosition = ({ position = 0, type = '', updating = false }:KeywordPositionProps) => {
if (!updating && position === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
if (updating && type !== 'sc') {
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
}
return <>{Math.round(position)}</>;
};
export default KeywordPosition;

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useUpdateKeywordTags } from '../../services/keywords';
import Icon from '../common/Icon';
import Modal from '../common/Modal';
import AddTags from './AddTags';
type keywordTagManagerProps = {
keyword: KeywordType|undefined,
@@ -10,9 +11,8 @@ type keywordTagManagerProps = {
}
const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
const [tagInput, setTagInput] = useState('');
const [inputError, setInputError] = useState('');
const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); });
const [showAddTag, setShowAddTag] = useState<boolean>(false);
const { mutate: updateMutate } = useUpdateKeywordTags(() => { });
const removeTag = (tag:String) => {
if (!keyword) { return; }
@@ -20,24 +20,6 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
updateMutate({ tags: { [keyword.ID]: newTags } });
};
const addTag = () => {
if (!keyword) { return; }
if (!tagInput) {
setInputError('Please Insert a Tag!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
if (keyword.tags.includes(tagInput)) {
setInputError('Tag Exist!');
setTimeout(() => { setInputError(''); }, 3000);
return;
}
console.log('New Tag: ', tagInput);
const newTags = [...keyword.tags, tagInput.trim()];
updateMutate({ tags: { [keyword.ID]: newTags } });
};
return (
<Modal closeModal={() => { closeModal(false); }} title={`Tags for Keyword "${keyword && keyword.keyword}"`}>
<div className="text-sm my-8 ">
@@ -53,31 +35,27 @@ const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => {
</button>
</li>;
})}
<li className='inline-block py-1 px-1'>
<button
title='Add New Tag'
className="cursor-pointer rounded p-1 px-3 bg-indigo-600 text-white font-semibold text-sm"
onClick={() => setShowAddTag(true)}>+</button>
</li>
</ul>
)}
{keyword && keyword.tags.length === 0 && (
<div className="text-center w-full text-gray-500">No Tags Added to this Keyword.</div>
<div className="text-center w-full text-gray-500">
No Tags Added to this Keyword. <button className=' text-indigo-600' onClick={() => setShowAddTag(true)}>+ Add Tag</button>
</div>
)}
</div>
<div className="relative">
{inputError && <span className="absolute top-[-24px] text-red-400 text-sm font-semibold">{inputError}</span>}
<span className='absolute text-gray-400 top-3 left-2'><Icon type="tags" size={16} /></span>
<input
className='w-full border rounded border-gray-200 py-3 px-4 pl-8 outline-none focus:border-indigo-300'
placeholder='Insert Tags'
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
addTag();
}
}}
/>
<button className=" absolute right-2 top-2 cursor-pointer rounded p-1 px-4 bg-blue-600 text-white font-bold" onClick={addTag}>+</button>
</div>
{showAddTag && keyword && (
<AddTags
keywords={[keyword]}
closeModal={() => setShowAddTag(false)}
/>
)}
</Modal>
);
};

View File

@@ -1,8 +1,9 @@
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { CSSTransition } from 'react-transition-group';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import AddKeywords from './AddKeywords';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter';
import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/client/sortFilter';
import Icon from '../common/Icon';
import Keyword from './Keyword';
import KeywordDetails from './KeywordDetails';
@@ -10,6 +11,9 @@ import KeywordFilters from './KeywordFilter';
import Modal from '../common/Modal';
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
import KeywordTagManager from './KeywordTagManager';
import AddTags from './AddTags';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
type KeywordsTableProps = {
domain: DomainType | null,
@@ -28,6 +32,8 @@ const KeywordsTable = (props: KeywordsTableProps) => {
const [showKeyDetails, setShowKeyDetails] = useState<KeywordType|null>(null);
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showTagManager, setShowTagManager] = useState<null|number>(null);
const [showAddTags, setShowAddTags] = useState<boolean>(false);
const [SCListHeight, setSCListHeight] = useState(500);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('date_asc');
const [scDataType, setScDataType] = useState<string>('threeDays');
@@ -35,13 +41,15 @@ const KeywordsTable = (props: KeywordsTableProps) => {
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
const { mutate: favoriteMutate } = useFavKeywords(() => {});
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const scDataObject:{ [k:string] : string} = {
threeDays: 'Last Three Days',
sevenDays: 'Last Seven Days',
thirtyDays: 'Last Thirty Days',
avgSevenDays: 'Last Three Days Avg',
avgThreeDays: 'Last Seven Days Avg',
avgThreeDays: 'Last Three Days Avg',
avgSevenDays: 'Last Seven Days Avg',
avgThirtyDays: 'Last Thirty Days Avg',
};
@@ -65,6 +73,27 @@ const KeywordsTable = (props: KeywordsTableProps) => {
}
setSelectedKeywords(updatedSelectd);
};
const Row = ({ data, index, style }:ListChildComponentProps) => {
const keyword = data[index];
return (
<Keyword
key={keyword.ID}
style={style}
index={index}
selected={selectedKeywords.includes(keyword.ID)}
selectKeyword={selectKeyword}
keywordData={keyword}
refreshkeyword={() => refreshMutate({ ids: [keyword.ID] })}
favoriteKeyword={favoriteMutate}
manageTags={() => setShowTagManager(keyword.ID)}
removeKeyword={() => { setSelectedKeywords([keyword.ID]); setShowRemoveModal(true); }}
showKeywordDetails={() => setShowKeyDetails(keyword)}
lastItem={index === (processedKeywords[device].length - 1)}
showSCData={showSCData}
scDataType={scDataType}
/>
);
};
const selectedAllItems = selectedKeywords.length === processedKeywords[device].length;
@@ -79,7 +108,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => { refreshMutate({ ids: selectedKeywords }); setSelectedKeywords([]); }}
>
<span className=' bg-indigo-100 text-blue-700 px-1 rounded'><Icon type="reload" size={11} /></span> Refresh Keyword
<span className=' bg-indigo-100 text-blue-700 px-1 rounded'><Icon type="reload" size={11} /></span> Refresh Keywords
</a>
</li>
<li className='inline-block mr-4'>
@@ -87,7 +116,14 @@ const KeywordsTable = (props: KeywordsTableProps) => {
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => setShowRemoveModal(true)}
>
<span className=' bg-red-100 text-red-600 px-1 rounded'><Icon type="trash" size={14} /></span> Remove Keyword</a>
<span className=' bg-red-100 text-red-600 px-1 rounded'><Icon type="trash" size={14} /></span> Remove Keywords</a>
</li>
<li className='inline-block mr-4'>
<a
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
onClick={() => setShowAddTags(true)}
>
<span className=' bg-green-100 text-green-500 px-1 rounded'><Icon type="tags" size={14} /></span> Tag Keywords</a>
</li>
</ul>
</div>
@@ -110,7 +146,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
<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-20 w-auto '>
<span className='domKeywords_head_keyword flex-1 basis-[4rem] w-auto '>
{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
@@ -122,8 +158,9 @@ const KeywordsTable = (props: KeywordsTableProps) => {
)}
Keyword
</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>Position</span>
<span className='domKeywords_head_history flex-1'>History (7d)</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-32 grow-0 '>History (7d)</span>
<span className='domKeywords_head_url flex-1'>URL</span>
<span className='domKeywords_head_updated flex-1'>Updated</span>
{showSCData && (
@@ -132,7 +169,7 @@ const KeywordsTable = (props: KeywordsTableProps) => {
<div>
<div
className=' w-48 select-none cursor-pointer absolute bg-white rounded-full
px-2 py-[2px] mt-[-22px] ml-3 border border-gray-200 z-50'
px-2 py-[2px] mt-[-22px] ml-3 border border-gray-200 z-40'
onClick={() => setShowScDataTypes(!showScDataTypes)}>
<Icon type="google" size={13} /> {scDataObject[scDataType]}
<Icon classes="ml-2" type={showScDataTypes ? 'caret-up' : 'caret-down'} size={10} />
@@ -161,21 +198,19 @@ const KeywordsTable = (props: KeywordsTableProps) => {
)}
</div>
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative'>
{processedKeywords[device] && processedKeywords[device].length > 0
&& processedKeywords[device].map((keyword, index) => <Keyword
key={keyword.ID}
selected={selectedKeywords.includes(keyword.ID)}
selectKeyword={selectKeyword}
keywordData={keyword}
refreshkeyword={() => refreshMutate({ ids: [keyword.ID] })}
favoriteKeyword={favoriteMutate}
manageTags={() => setShowTagManager(keyword.ID)}
removeKeyword={() => { setSelectedKeywords([keyword.ID]); setShowRemoveModal(true); }}
showKeywordDetails={() => setShowKeyDetails(keyword)}
lastItem={index === (processedKeywords[device].length - 1)}
showSCData={showSCData}
scDataType={scDataType}
/>)}
{processedKeywords[device] && processedKeywords[device].length > 0 && (
<List
innerElementType="div"
itemData={processedKeywords[device]}
itemCount={processedKeywords[device].length}
itemSize={isMobile ? 146 : 57}
height={SCListHeight}
width={'100%'}
className={'styled-scrollbar'}
>
{Row}
</List>
)}
{!isLoading && processedKeywords[device].length === 0 && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>No Keywords Added for this Device Type.</p>
)}
@@ -222,6 +257,12 @@ const KeywordsTable = (props: KeywordsTableProps) => {
closeModal={() => setShowTagManager(null)}
/>
)}
{showAddTags && (
<AddTags
keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))}
closeModal={() => setShowAddTags(false)}
/>
)}
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);

View File

@@ -1,6 +1,8 @@
import React from 'react';
import Icon from '../common/Icon';
import countries from '../../utils/countries';
import KeywordPosition from './KeywordPosition';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordProps = {
keywordData: SearchAnalyticsItem,
@@ -15,13 +17,6 @@ const SCKeyword = (props: SCKeywordProps) => {
const { keywordData, selected, lastItem, selectKeyword, style, isTracked = false } = props;
const { keyword, uid, position, country, impressions, ctr, clicks } = keywordData;
const renderPosition = () => {
if (position === 0) {
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
}
return Math.round(position);
};
return (
<div
key={keyword}
@@ -45,7 +40,7 @@ const SCKeyword = (props: SCKeywordProps) => {
<div className={`keyword_position absolute bg-[#f8f9ff] w-fit min-w-[50px] h-15 p-2 text-base mt-[-20px] rounded right-5 lg:relative
lg:bg-transparent lg:w-auto lg:h-auto lg:mt-0 lg:p-0 lg:text-sm lg:flex-1 lg:basis-40 lg:grow-0 lg:right-0 text-center font-semibold`}>
{renderPosition()}
<KeywordPosition position={position} />
<span className='block text-xs text-gray-500 lg:hidden'>Position</span>
</div>
@@ -53,14 +48,14 @@ const SCKeyword = (props: SCKeywordProps) => {
<span className='mr-3 lg:hidden'>
<Icon type="eye" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(impressions)}
{formattedNum(impressions)}
</div>
<div className={'keyword_visits text-center inline-block mt-4 mr-5 ml-5 lg:flex-1 lg:m-0 max-w-[70px] lg:max-w-none lg:pr-5'}>
<span className='mr-3 lg:hidden'>
<Icon type="cursor" size={14} color="#999" />
</span>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(clicks)}
{formattedNum(clicks)}
</div>
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>

View File

@@ -1,12 +1,15 @@
import { useRouter } from 'next/router';
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import { Toaster } from 'react-hot-toast';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import { useAddKeywords, useFetchKeywords } from '../../services/keywords';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/SCsortFilter';
import { SCfilterKeywords, SCkeywordsByDevice, SCsortKeywords } from '../../utils/client/SCsortFilter';
import Icon from '../common/Icon';
import KeywordFilters from './KeywordFilter';
import SCKeyword from './SCKeyword';
import useWindowResize from '../../hooks/useWindowResize';
import useIsMobile from '../../hooks/useIsMobile';
import { formattedNum } from '../../utils/client/helpers';
type SCKeywordsTableProps = {
domain: DomainType | null,
@@ -26,12 +29,14 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
const [device, setDevice] = useState<string>('desktop');
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [filterParams, setFilterParams] = useState<KeywordFilters>({ countries: [], tags: [], search: '' });
const [sortBy, setSortBy] = useState<string>('imp_asc');
const [isMobile, setIsMobile] = useState<boolean>(false);
const [sortBy, setSortBy] = useState<string>('imp_desc');
const [SCListHeight, setSCListHeight] = useState(500);
const { keywordsData } = useFetchKeywords(router);
const addedkeywords: string[] = keywordsData?.keywords?.map((key: KeywordType) => `${key.keyword}:${key.country}:${key.device}`) || [];
const { mutate: addKeywords } = useAddKeywords(() => { if (domain && domain.slug) router.push(`/domain/${domain.slug}`); });
const [isMobile] = useIsMobile();
useWindowResize(() => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400)));
const finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
const procKeywords = keywords.filter((x) => x.device === device);
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
@@ -71,16 +76,6 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
};
}, [finalKeywords, device]);
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
const resizeList = () => setSCListHeight(window.innerHeight - (isMobile ? 200 : 400));
resizeList();
window.addEventListener('resize', resizeList);
return () => {
window.removeEventListener('resize', resizeList);
};
}, [isMobile]);
const selectKeyword = (keywordID: string) => {
console.log('Select Keyword: ', keywordID);
let updatedSelectd = [...selectedKeywords, keywordID];
@@ -194,10 +189,10 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
</span>
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{viewSummary.position}</span>
<span className='domKeywords_head_imp flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.impressions)}
{formattedNum(viewSummary.impressions)}
</span>
<span className='domKeywords_head_visits flex-1 text-center'>
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.visits)}
{formattedNum(viewSummary.visits)}
</span>
<span className='domKeywords_head_ctr flex-1 text-center'>
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%
@@ -214,7 +209,7 @@ const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleInt
)}
{!isConsoleIntegrated && (
<p className=' p-9 pt-[10%] text-center text-gray-500'>
Goolge Search has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
Google Search has not been Integrated yet. Please follow <a className='text-indigo-600 underline' href='https://docs.serpbear.com/miscellaneous/integrate-google-search-console' target="_blank" rel='noreferrer'>These Steps</a> to integrate Google Search Data for this Domain.
</p>
)}
</div>

View File

@@ -0,0 +1,111 @@
import React from 'react';
import SelectField from '../common/SelectField';
import SecretField from '../common/SecretField';
type NotificationSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const NotificationSettings = ({ settings, settingsError, updateSettings }:NotificationSettingsProps) => {
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Frequency</label>
<SelectField
multiple={false}
selected={[settings.notification_interval]}
options={[
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
]}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
/>
</div>
{settings.notification_interval !== 'never' && (
<>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Emails</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email}
placeholder={'test@gmail.com'}
onChange={(event) => updateSettings('notification_email', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Server</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_smtp_server' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_server || ''}
onChange={(event) => updateSettings('smtp_server', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Port</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_port' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_port || ''}
onChange={(event) => updateSettings('smtp_port', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Username</label>
<input
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
type="text"
value={settings?.smtp_username || ''}
onChange={(event) => updateSettings('smtp_username', event.target.value)}
/>
</div>
<SecretField
label='SMTP Password'
value={settings?.smtp_password || ''}
onChange={(value:string) => updateSettings('smtp_password', value)}
/>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError?.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
/>
</div>
</>
)}
</div>
{settingsError?.msg && (
<div className='absolute w-full bottom-16 text-center p-3 bg-red-100 text-red-600 text-sm font-semibold'>
{settingsError.msg}
</div>
)}
</div>
);
};
export default NotificationSettings;

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { useClearFailedQueue } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import SecretField from '../common/SecretField';
type ScraperSettingsProps = {
settings: SettingsType,
settingsError: null | {
type: string,
msg: string
},
updateSettings: Function,
}
const ScraperSettings = ({ settings, settingsError, updateSettings }:ScraperSettingsProps) => {
const { mutate: clearFailedMutate, isLoading: clearingQueue } = useClearFailedQueue(() => {});
const scrapingOptions: SelectionOption[] = [
{ label: 'Daily', value: 'daily' },
{ label: 'Every Other Day', value: 'other_day' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
];
const delayOptions: SelectionOption[] = [
{ label: 'No Delay', value: '0' },
{ label: '5 Seconds', value: '5000' },
{ label: '10 Seconds', value: '10000' },
{ label: '30 Seconds', value: '30000' },
{ label: '1 Minutes', value: '60000' },
{ label: '2 Minutes', value: '120000' },
{ label: '5 Minutes', value: '300000' },
{ label: '10 Minutes', value: '600000' },
{ label: '15 Minutes', value: '900000' },
{ label: '30 Minutes', value: '1800000' },
];
const allScrapers: SelectionOption[] = settings.available_scapers ? settings.available_scapers : [];
const scraperOptions: SelectionOption[] = [{ label: 'None', value: 'none' }, ...allScrapers];
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
return (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__select mb-5">
<label className={labelStyle}>Scraping Method</label>
<SelectField
options={scraperOptions}
selected={[settings.scraper_type || 'none']}
defaultLabel="Select Scraper"
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
multiple={false}
rounded={'rounded'}
minWidth={270}
/>
</div>
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi', 'spaceSerp', 'searchapi'].includes(settings.scraper_type) && (
<SecretField
label='Scraper API Key or Token'
placeholder={'API Key/Token'}
value={settings?.scaping_api || ''}
hasError={settingsError?.type === 'no_api_key'}
onChange={(value:string) => updateSettings('scaping_api', value)}
/>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Proxy List</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none min-h-[160px] focus:border-blue-200
${settingsError?.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
value={settings?.proxy}
placeholder={'http://122.123.22.45:5049\nhttps://user:password@122.123.22.45:5049'}
onChange={(event) => updateSettings('proxy', event.target.value)}
/>
</div>
)}
{settings.scraper_type !== 'none' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Scraping Frequency</label>
<SelectField
multiple={false}
selected={[settings?.scrape_interval || 'daily']}
options={scrapingOptions}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
)}
<div className="settings__section__input mb-5">
<label className={labelStyle}>Delay Between Each keyword Scrape</label>
<SelectField
multiple={false}
selected={[settings?.scrape_delay || '0']}
options={delayOptions}
defaultLabel={'Delay Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('scrape_delay', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
/>
<small className=' text-gray-500 pt-2 block'>This option requires Server/Docker Instance Restart to take Effect.</small>
</div>
<div className="settings__section__input mb-5">
<label className="relative inline-flex items-center cursor-pointer w-full justify-between">
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 w-56">Auto Retry Failed Keyword Scrape</span>
<input
type="checkbox"
value={settings?.scrape_retry ? 'true' : '' }
checked={settings.scrape_retry || false}
className="sr-only peer"
onChange={() => updateSettings('scrape_retry', !settings.scrape_retry)}
/>
<div className="relative rounded-3xl w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800rounded-full peer dark:bg-gray-700
peer-checked:after:translate-x-full peer-checked:after:border-white after:content-['']
after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300
after:border after:rounded-full after:h-4 after:w-4
after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{settings?.scrape_retry && (settings.failed_queue?.length || 0) > 0 && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Clear Failed Retry Queue</label>
<button
onClick={() => clearFailedMutate()}
className=' py-3 px-5 w-full rounded cursor-pointer bg-gray-100 text-gray-800
font-semibold text-sm hover:bg-gray-200'>
{clearingQueue && <Icon type="loading" size={14} />} Clear Failed Queue
({settings.failed_queue?.length || 0} Keywords)
</button>
</div>
)}
</div>
</div>
);
};
export default ScraperSettings;

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react';
// import { useQuery } from 'react-query';
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
import { Toaster } from 'react-hot-toast';
import { useFetchSettings, useUpdateSettings } from '../../services/settings';
import Icon from '../common/Icon';
import SelectField, { SelectionOption } from '../common/SelectField';
import NotificationSettings from './NotificationSettings';
import ScraperSettings from './ScraperSettings';
import useOnKey from '../../hooks/useOnKey';
type SettingsProps = {
closeSettings: Function,
@@ -16,6 +18,8 @@ type SettingsError = {
const defaultSettings = {
scraper_type: 'none',
scrape_delay: 'none',
scrape_retry: false,
notification_interval: 'daily',
notification_email: '',
smtp_server: '',
@@ -31,6 +35,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
const [settingsError, setSettingsError] = useState<SettingsError|null>(null);
const { mutate: updateMutate, isLoading: isUpdating } = useUpdateSettings(() => console.log(''));
const { data: appSettings, isLoading } = useFetchSettings();
useOnKey('Escape', closeSettings);
useEffect(() => {
if (appSettings && appSettings.settings) {
@@ -38,46 +43,32 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
}, [appSettings]);
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
console.log(event.key);
closeSettings();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [closeSettings]);
const closeOnBGClick = (e:React.SyntheticEvent) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
if (e.target === e.currentTarget) { closeSettings(); }
};
const updateSettings = (key: string, value:string|number) => {
const updateSettings = (key: string, value:string|number|boolean) => {
setSettings({ ...settings, [key]: value });
};
const performUpdate = () => {
let error: null|SettingsError = null;
if (settings.notification_interval !== 'never') {
const { notification_interval, notification_email, notification_email_from, scraper_type, smtp_port, smtp_server, scaping_api } = settings;
if (notification_interval !== 'never') {
if (!settings.notification_email) {
error = { type: 'no_email', msg: 'Insert a Valid Email address' };
}
if (settings.notification_email
&& (!settings.smtp_port || !settings.smtp_server
|| !settings.notification_email_from)) {
if (notification_email && (!smtp_port || !smtp_server || !notification_email_from)) {
let type = 'no_smtp_from';
if (!settings.smtp_port) { type = 'no_smtp_port'; }
if (!settings.smtp_server) { type = 'no_smtp_server'; }
if (!smtp_port) { type = 'no_smtp_port'; }
if (!smtp_server) { type = 'no_smtp_server'; }
error = { type, msg: 'Insert SMTP Server details that will be used to send the emails.' };
}
}
if (['scrapingant', 'scrapingrobot', 'serply', 'serpapi'].includes(settings.scraper_type) && !settings.scaping_api) {
if (scraper_type !== 'proxy' && scraper_type !== 'none' && !scaping_api) {
error = { type: 'no_api_key', msg: 'Insert a Valid API Key or Token for the Scraper Service.' };
}
@@ -90,23 +81,6 @@ const Settings = ({ closeSettings }:SettingsProps) => {
}
};
const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700 capitalize';
const notificationOptions: SelectionOption[] = [
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Never', value: 'never' },
];
const scraperOptions: SelectionOption[] = [
{ label: 'None', value: 'none' },
{ label: 'Proxy', value: 'proxy' },
{ label: 'ScrapingAnt.com', value: 'scrapingant' },
{ label: 'ScrapingRobot.com', value: 'scrapingrobot' },
{ label: 'serply.io', value: 'serply' },
{ label: 'serpapi.com', value: 'serpapi' },
];
const tabStyle = 'inline-block px-4 py-1 rounded-full mr-3 cursor-pointer text-sm';
return (
<div className="settings fixed w-full h-screen top-0 left-0 z-50" onClick={closeOnBGClick}>
@@ -134,141 +108,13 @@ const Settings = ({ closeSettings }:SettingsProps) => {
</li>
</ul>
</div>
{currentTab === 'scraper' && (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__select mb-5">
<label className={labelStyle}>Scraping Method</label>
<SelectField
options={scraperOptions}
selected={[settings.scraper_type || 'none']}
defaultLabel="Select Scraper"
updateField={(updatedTime:[string]) => updateSettings('scraper_type', updatedTime[0])}
multiple={false}
rounded={'rounded'}
minWidth={270}
/>
</div>
{['scrapingant', 'scrapingrobot', 'serply', 'serpapi'].includes(settings.scraper_type) && (
<div className="settings__section__input mr-3">
<label className={labelStyle}>Scraper API Key or Token</label>
<input
className={`w-full p-2 border border-gray-200 rounded mt-2 mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_api_key' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.scaping_api || ''}
placeholder={'API Key/Token'}
onChange={(event) => updateSettings('scaping_api', event.target.value)}
/>
</div>
)}
{settings.scraper_type === 'proxy' && (
<div className="settings__section__input mb-5">
<label className={labelStyle}>Proxy List</label>
<textarea
className={`w-full p-2 border border-gray-200 rounded mb-3 text-xs
focus:outline-none min-h-[160px] focus:border-blue-200
${settingsError && settingsError.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
value={settings?.proxy}
placeholder={'http://122.123.22.45:5049\nhttps://user:password@122.123.22.45:5049'}
onChange={(event) => updateSettings('proxy', event.target.value)}
/>
</div>
)}
</div>
</div>
{currentTab === 'scraper' && settings && (
<ScraperSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
{currentTab === 'notification' && (
<div>
<div className='settings__content styled-scrollbar p-6 text-sm'>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Frequency</label>
<SelectField
multiple={false}
selected={[settings.notification_interval]}
options={notificationOptions}
defaultLabel={'Notification Settings'}
updateField={(updated:string[]) => updated[0] && updateSettings('notification_interval', updated[0])}
rounded='rounded'
maxHeight={48}
minWidth={270}
/>
</div>
{settings.notification_interval !== 'never' && (
<>
<div className="settings__section__input mb-5">
<label className={labelStyle}>Notification Emails</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_email' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email}
placeholder={'test@gmail.com'}
onChange={(event) => updateSettings('notification_email', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Server</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_server' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_server || ''}
onChange={(event) => updateSettings('smtp_server', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Port</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_port' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.smtp_port || ''}
onChange={(event) => updateSettings('smtp_port', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Username</label>
<input
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
type="text"
value={settings?.smtp_username || ''}
onChange={(event) => updateSettings('smtp_username', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>SMTP Password</label>
<input
className={'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'}
type="text"
value={settings?.smtp_password || ''}
onChange={(event) => updateSettings('smtp_password', event.target.value)}
/>
</div>
<div className="settings__section__input mb-5">
<label className={labelStyle}>From Email Address</label>
<input
className={`w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200
${settingsError && settingsError.type === 'no_smtp_from' ? ' border-red-400 focus:border-red-400' : ''} `}
type="text"
value={settings?.notification_email_from || ''}
placeholder="no-reply@mydomain.com"
onChange={(event) => updateSettings('notification_email_from', event.target.value)}
/>
</div>
</>
)}
</div>
{settingsError && (
<div className='absolute w-full bottom-16 text-center p-3 bg-red-100 text-red-600 text-sm font-semibold'>
{settingsError.msg}
</div>
)}
</div>
)}
{currentTab === 'notification' && settings && (
<NotificationSettings settings={settings} updateSettings={updateSettings} settingsError={settingsError} />
)}
<div className=' border-t-[1px] border-gray-200 p-2 px-3'>
<button
onClick={() => performUpdate()}
@@ -277,6 +123,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
</button>
</div>
</div>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};

124
cron.js
View File

@@ -1,7 +1,7 @@
const Cryptr = require('cryptr');
const { promises } = require('fs');
const { readFile } = require('fs');
const cron = require('node-cron');
const Cron = require('croner');
require('dotenv').config({ path: './.env.local' });
const getAppSettings = async () => {
@@ -49,11 +49,14 @@ const generateCronTime = (interval) => {
if (interval === 'daily') {
cronTime = '0 0 0 * * *';
}
if (interval === 'other_day') {
cronTime = '0 0 2-30/2 * *';
}
if (interval === 'daily_morning') {
cronTime = '0 0 3 * * *';
}
if (interval === 'weekly') {
cronTime = '0 0 0 */7 * *';
cronTime = '0 0 * * 1';
}
if (interval === 'monthly') {
cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight)
@@ -63,66 +66,30 @@ const generateCronTime = (interval) => {
};
const runAppCronJobs = () => {
// RUN SERP Scraping CRON (EveryDay at Midnight) 0 0 0 * *
const scrapeCronTime = generateCronTime('daily');
cron.schedule(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)
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Daily Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
// Run Failed scraping CRON (Every Hour)
const failedCronTime = generateCronTime('hourly');
cron.schedule(failedCronTime, () => {
// console.log('### Retrying Failed Scrapes...');
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
if (data) {
const keywordsToRetry = data ? JSON.parse(data) : [];
if (keywordsToRetry.length > 0) {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts)
.then((res) => res.json())
.then((refreshedData) => console.log(refreshedData))
.catch((fetchErr) => {
console.log('ERROR Making failed_queue Cron Request..');
console.log(fetchErr);
});
}
} else {
console.log('ERROR Reading Failed Scrapes Queue File..', err);
}
});
}, { scheduled: true });
// Run Google Search Console Scraper Daily
if (process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) {
const searchConsoleCRONTime = generateCronTime('daily');
cron.schedule(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())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Google Search Console Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
// RUN Email Notification CRON
getAppSettings().then((settings) => {
// RUN SERP Scraping CRON (EveryDay at Midnight) 0 0 0 * *
const scrape_interval = settings.scrape_interval || 'daily';
if (scrape_interval !== 'never') {
const scrapeCronTime = generateCronTime(scrape_interval);
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)
.then((res) => res.json())
// .then((data) =>{ console.log(data)})
.catch((err) => {
console.log('ERROR Making SERP Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
// RUN Email Notification CRON
const notif_interval = (!settings.notification_interval || settings.notification_interval === 'never') ? false : settings.notification_interval;
if (notif_interval) {
const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval);
if (cronTime) {
cron.schedule(cronTime, () => {
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)
@@ -136,6 +103,49 @@ const runAppCronJobs = () => {
}
}
});
// Run Failed scraping CRON (Every Hour)
const failedCronTime = generateCronTime('hourly');
Cron(failedCronTime, () => {
// console.log('### Retrying Failed Scrapes...');
readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => {
if (data) {
try {
const keywordsToRetry = data ? JSON.parse(data) : [];
if (keywordsToRetry.length > 0) {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts)
.then((res) => res.json())
.then((refreshedData) => console.log(refreshedData))
.catch((fetchErr) => {
console.log('ERROR Making failed_queue Cron Request..');
console.log(fetchErr);
});
}
} catch (error) {
console.log('ERROR Reading Failed Scrapes Queue File..', error);
}
} else {
console.log('ERROR Reading Failed Scrapes Queue File..', err);
}
});
}, { scheduled: true });
// 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, () => {
const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/searchconsole`, fetchOpts)
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
console.log('ERROR Making Google Search Console Scraper Cron Request..');
console.log(err);
});
}, { scheduled: true });
}
};
runAppCronJobs();

View File

@@ -6,7 +6,7 @@ import Keyword from './models/keyword';
const connection = new Sequelize({
dialect: 'sqlite',
host: '0.0.0.0',
username: process.env.USER,
username: process.env.USER_NAME ? process.env.USER_NAME : process.env.USER,
password: process.env.PASSWORD,
database: 'sequelize',
dialectModule: sqlite3,

12
hooks/useIsMobile.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
setIsMobile(!!(window.matchMedia('only screen and (max-width: 760px)').matches));
}, []);
return [isMobile];
};
export default useIsMobile;

17
hooks/useOnKey.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { useEffect } from 'react';
const useOnKey = (key:string, onPress: Function) => {
useEffect(() => {
const closeModalonEsc = (event:KeyboardEvent) => {
if (event.key === key) {
onPress();
}
};
window.addEventListener('keydown', closeModalonEsc, false);
return () => {
window.removeEventListener('keydown', closeModalonEsc, false);
};
}, [key, onPress]);
};
export default useOnKey;

13
hooks/useWindowResize.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
const useWindowResize = (onResize: () => void) => {
useEffect(() => {
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [onResize]);
};
export default useWindowResize;

View File

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

View File

@@ -1,8 +1,13 @@
/** @type {import('next').NextConfig} */
const { version } = require('./package.json');
const nextConfig = {
reactStrictMode: true,
swcMinify: false,
output: 'standalone',
publicRuntimeConfig: {
version,
},
};
module.exports = nextConfig;

15376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "serpbear",
"version": "0.2.0",
"version": "0.3.4",
"private": true,
"scripts": {
"dev": "next dev",
@@ -17,7 +17,6 @@
},
"dependencies": {
"@googleapis/searchconsole": "^1.0.0",
"@testing-library/react": "^13.4.0",
"@types/react-transition-group": "^4.4.5",
"axios": "^1.1.3",
"axios-retry": "^3.3.1",
@@ -25,15 +24,15 @@
"cheerio": "^1.0.0-rc.12",
"concurrently": "^7.6.0",
"cookies": "^0.8.0",
"croner": "^5.3.5",
"cryptr": "^6.0.3",
"dayjs": "^1.11.5",
"dotenv": "^16.0.3",
"https-proxy-agent": "^5.0.1",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.2",
"msw": "^0.49.0",
"next": "12.3.1",
"node-cron": "^3.0.2",
"next": "^12.3.4",
"nodemailer": "^6.8.0",
"react": "18.2.0",
"react-chartjs-2": "^4.3.1",
@@ -44,15 +43,17 @@
"react-transition-group": "^4.4.5",
"react-window": "^1.8.8",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.25.2",
"sequelize": "^6.34.0",
"sequelize-typescript": "^2.1.5",
"sqlite3": "^5.1.2"
"sqlite3": "^5.1.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@types/cookies": "^0.7.7",
"@types/cryptr": "^4.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.8",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.6",
@@ -64,9 +65,11 @@
"eslint": "8.25.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-next": "12.3.1",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"postcss": "^8.4.18",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"next-router-mock": "^0.9.10",
"postcss": "^8.4.31",
"prettier": "^2.7.1",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.55.0",

29
pages/api/clearfailed.ts Normal file
View File

@@ -0,0 +1,29 @@
import { writeFile } from 'fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
import verifyUser from '../../utils/verifyUser';
type SettingsGetResponse = {
cleared?: boolean,
error?: string,
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authorized = verifyUser(req, res);
if (authorized !== 'authorized') {
return res.status(401).json({ error: authorized });
}
if (req.method === 'PUT') {
return clearFailedQueue(req, res);
}
return res.status(502).json({ error: 'Unrecognized Route.' });
}
const clearFailedQueue = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
try {
await writeFile(`${process.cwd()}/data/failed_queue.json`, JSON.stringify([]), { encoding: 'utf-8' });
return res.status(200).json({ cleared: true });
} catch (error) {
console.log('[ERROR] Cleraring Failed Queue File.', error);
return res.status(200).json({ error: 'Error Cleraring Failed Queue!' });
}
};

View File

@@ -3,7 +3,7 @@ import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import { refreshAndUpdateKeywords } from './refresh';
import refreshAndUpdateKeywords from '../../utils/refresh';
type CRONRefreshRes = {
started: boolean

View File

@@ -4,6 +4,7 @@ import Domain from '../../database/models/domain';
import Keyword from '../../database/models/keyword';
import getdomainStats from '../../utils/domains';
import verifyUser from '../../utils/verifyUser';
import { removeLocalSCData } from '../../utils/searchConsole';
type DomainsGetRes = {
domains: DomainType[]
@@ -11,13 +12,14 @@ type DomainsGetRes = {
}
type DomainsAddResponse = {
domain: Domain|null,
domains: DomainType[]|null,
error?: string|null,
}
type DomainsDeleteRes = {
domainRemoved: number,
keywordsRemoved: number,
SCDataRemoved: boolean,
error?: string|null,
}
@@ -59,41 +61,45 @@ export const getDomains = async (req: NextApiRequest, res: NextApiResponse<Domai
}
};
export const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
if (!req.body.domain) {
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
}
const { domain } = req.body || {};
const domainData = {
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
};
const addDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsAddResponse>) => {
const { domains } = req.body;
if (domains && Array.isArray(domains) && domains.length > 0) {
const domainsToAdd: any = [];
try {
const addedDomain = await Domain.create(domainData);
return res.status(201).json({ domain: addedDomain });
} catch (error) {
return res.status(400).json({ domain: null, error: 'Error Adding Domain.' });
domains.forEach((domain: string) => {
domainsToAdd.push({
domain: domain.trim(),
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
lastUpdated: new Date().toJSON(),
added: new Date().toJSON(),
});
});
try {
const newDomains:Domain[] = await Domain.bulkCreate(domainsToAdd);
const formattedDomains = newDomains.map((el) => el.get({ plain: true }));
return res.status(201).json({ domains: formattedDomains });
} catch (error) {
console.log('[ERROR] Adding New Domain ', error);
return res.status(400).json({ domains: [], error: 'Error Adding Domain.' });
}
} else {
return res.status(400).json({ domains: [], error: 'Necessary data missing.' });
}
};
export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<DomainsDeleteRes>) => {
if (!req.query.domain && typeof req.query.domain !== 'string') {
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Domain is Required!' });
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Domain is Required!' });
}
try {
const { domain } = req.query || {};
const removedDomCount: number = await Domain.destroy({ where: { domain } });
const removedKeywordCount: number = await Keyword.destroy({ where: { domain } });
return res.status(200).json({
domainRemoved: removedDomCount,
keywordsRemoved: removedKeywordCount,
});
const SCDataRemoved = await removeLocalSCData(domain as string);
return res.status(200).json({ domainRemoved: removedDomCount, keywordsRemoved: removedKeywordCount, SCDataRemoved });
} catch (error) {
console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, SCDataRemoved: false, error: 'Error Deleting Domain' });
}
};

View File

@@ -37,7 +37,10 @@ const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiRe
// First try and read the Local SC Domain Data file.
const localSCData = await readLocalSCData(domainname);
if (localSCData && localSCData.stats && localSCData.stats.length) {
const oldFetchedDate = localSCData.lastFetched;
const fetchTimeDiff = new Date().getTime() - (oldFetchedDate ? new Date(oldFetchedDate as string).getTime() : 0);
if (localSCData && localSCData.stats && localSCData.stats.length && fetchTimeDiff <= 86400000) {
const response = getInsightFromSCData(localSCData);
return res.status(200).json({ data: response });
}

View File

@@ -2,11 +2,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import { refreshAndUpdateKeywords } from './refresh';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords';
import { integrateKeywordSCData, readLocalSCData } from '../../utils/searchConsole';
import refreshAndUpdateKeywords from '../../utils/refresh';
type KeywordsGetResponse = {
keywords?: KeywordType[],
@@ -153,10 +153,13 @@ const updateKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywords
}
if (tags) {
const tagsKeywordIDs = Object.keys(tags);
const multipleKeywords = tagsKeywordIDs.length > 1;
for (const keywordID of tagsKeywordIDs) {
const response = await Keyword.findOne({ where: { ID: keywordID } });
if (response) {
await response.update({ tags: JSON.stringify(tags[keywordID]) });
const selectedKeyword = await Keyword.findOne({ where: { ID: keywordID } });
const currentTags = selectedKeyword && selectedKeyword.tags ? JSON.parse(selectedKeyword.tags) : [];
const mergedTags = Array.from(new Set([...currentTags, ...tags[keywordID]]));
if (selectedKeyword) {
await selectedKeyword.update({ tags: JSON.stringify(multipleKeywords ? mergedTags : tags[keywordID]) });
}
}
return res.status(200).json({ keywords });

View File

@@ -18,10 +18,11 @@ const loginUser = async (req: NextApiRequest, res: NextApiResponse<loginResponse
if (!req.body.username || !req.body.password) {
return res.status(401).json({ error: 'Username Password Missing' });
}
const userName = process.env.USER_NAME ? process.env.USER_NAME : process.env.USER;
if (req.body.username === process.env.USER
if (req.body.username === userName
&& req.body.password === process.env.PASSWORD && process.env.SECRET) {
const token = jwt.sign({ user: process.env.USER }, process.env.SECRET);
const token = jwt.sign({ user: userName }, process.env.SECRET);
const cookies = new Cookies(req, res);
const expireDate = new Date();
const sessDuration = process.env.SESSION_DURATION;
@@ -30,7 +31,7 @@ const loginUser = async (req: NextApiRequest, res: NextApiResponse<loginResponse
return res.status(200).json({ success: true, error: null });
}
const error = req.body.username !== process.env.USER ? 'Incorrect Username' : 'Incorrect Password';
const error = req.body.username !== userName ? 'Incorrect Username' : 'Incorrect Password';
return res.status(401).json({ success: false, error });
};

View File

@@ -2,11 +2,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { Op } from 'sequelize';
import db from '../../database/database';
import Keyword from '../../database/models/keyword';
import refreshKeywords from '../../utils/refresh';
import refreshAndUpdateKeywords from '../../utils/refresh';
import { getAppSettings } from './settings';
import verifyUser from '../../utils/verifyUser';
import parseKeywords from '../../utils/parseKeywords';
import { removeFromRetryQueue, retryScrape } from '../../utils/scraper';
type KeywordsRefreshRes = {
keywords?: KeywordType[]
@@ -63,57 +62,3 @@ const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywo
return res.status(400).json({ error: 'Error refreshing keywords!' });
}
};
export const refreshAndUpdateKeywords = async (initKeywords:Keyword[], settings:SettingsType) => {
const formattedKeywords = initKeywords.map((el) => el.get({ plain: true }));
const refreshed: any = await refreshKeywords(formattedKeywords, settings);
// const fetchKeywords = await refreshKeywords(initialKeywords.map( k=> k.keyword ));
const updatedKeywords: KeywordType[] = [];
for (const keywordRaw of initKeywords) {
const keywordPrased = parseKeywords([keywordRaw.get({ plain: true })]);
const keyword = keywordPrased[0];
const udpatedkeyword = refreshed.find((item:any) => item.ID && item.ID === keyword.ID);
if (udpatedkeyword && keyword) {
const newPos = udpatedkeyword.position;
const newPosition = newPos !== false ? newPos : keyword.position;
const { history } = keyword;
const theDate = new Date();
history[`${theDate.getFullYear()}-${theDate.getMonth() + 1}-${theDate.getDate()}`] = newPosition;
const updatedVal = {
position: newPosition,
updating: false,
url: udpatedkeyword.url,
lastResult: udpatedkeyword.result,
history,
lastUpdated: udpatedkeyword.error ? keyword.lastUpdated : theDate.toJSON(),
lastUpdateError: udpatedkeyword.error
? JSON.stringify({ date: theDate.toJSON(), error: `${udpatedkeyword.error}`, scraper: settings.scraper_type })
: 'false',
};
updatedKeywords.push({ ...keyword, ...{ ...updatedVal, lastUpdateError: JSON.parse(updatedVal.lastUpdateError) } });
// If failed, Add to Retry Queue Cron
if (udpatedkeyword.error) {
await retryScrape(keyword.ID);
} else {
await removeFromRetryQueue(keyword.ID);
}
// Update the Keyword Position in Database
try {
await keywordRaw.update({
...updatedVal,
lastResult: Array.isArray(udpatedkeyword.result) ? JSON.stringify(udpatedkeyword.result) : udpatedkeyword.result,
history: JSON.stringify(history),
});
console.log('[SUCCESS] Updating the Keyword: ', keyword.keyword);
} catch (error) {
console.log('[ERROR] Updating SERP for Keyword', keyword.keyword, error);
}
}
}
return updatedKeywords;
};

View File

@@ -1,7 +1,9 @@
import { writeFile, readFile } from 'fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
import Cryptr from 'cryptr';
import { writeFile, readFile } from 'fs/promises';
import getConfig from 'next/config';
import verifyUser from '../../utils/verifyUser';
import allScrapers from '../../scrapers/index';
type SettingsGetResponse = {
settings?: object | null,
@@ -25,7 +27,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const getSettings = async (req: NextApiRequest, res: NextApiResponse<SettingsGetResponse>) => {
const settings = await getAppSettings();
if (settings) {
return res.status(200).json({ settings });
const { publicRuntimeConfig } = getConfig();
const version = publicRuntimeConfig?.version;
return res.status(200).json({ settings: { ...settings, version } });
}
return res.status(400).json({ error: 'Error Loading Settings!' });
};
@@ -51,8 +55,11 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
};
export const getAppSettings = async () : Promise<SettingsType> => {
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
try {
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
const failedQueueRaw = await readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' });
const failedQueue: string[] = failedQueueRaw ? JSON.parse(failedQueueRaw) : [];
const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {};
let decryptedSettings = settings;
@@ -65,6 +72,9 @@ export const getAppSettings = async () : Promise<SettingsType> => {
scaping_api,
smtp_password,
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
failed_queue: failedQueue,
screenshot_key: screenshotAPIKey,
};
} catch (error) {
console.log('Error Decrypting Settings API Keys!');
@@ -73,7 +83,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
return decryptedSettings;
} catch (error) {
console.log('[ERROR] Getting App Settings. ', error);
const settings = {
const settings: SettingsType = {
scraper_type: 'none',
notification_interval: 'never',
notification_email: '',
@@ -82,8 +92,15 @@ export const getAppSettings = async () : Promise<SettingsType> => {
smtp_port: '',
smtp_username: '',
smtp_password: '',
scrape_retry: false,
screenshot_key: screenshotAPIKey,
};
const otherSettings = {
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
failed_queue: [],
};
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' });
return settings;
await writeFile(`${process.cwd()}/data/failed_queue.json`, JSON.stringify([]), { encoding: 'utf-8' });
return { ...settings, ...otherSettings };
}
};

View File

@@ -11,7 +11,7 @@ import DomainHeader from '../../../components/domains/DomainHeader';
import KeywordsTable from '../../../components/keywords/KeywordsTable';
import AddDomain from '../../../components/domains/AddDomain';
import DomainSettings from '../../../components/domains/DomainSettings';
import exportCSV from '../../../utils/exportcsv';
import exportCSV from '../../../utils/client/exportcsv';
import Settings from '../../../components/settings/Settings';
import { useFetchDomains } from '../../../services/domains';
import { useFetchKeywords } from '../../../services/keywords';
@@ -35,7 +35,7 @@ const SingleDomain: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -86,7 +86,7 @@ const SingleDomain: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -10,7 +10,7 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCKeywords } from '../../../../services/searchConsole';
@@ -34,7 +34,7 @@ const DiscoverPage: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -71,7 +71,7 @@ const DiscoverPage: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -10,7 +10,7 @@ import TopBar from '../../../../components/common/TopBar';
import DomainHeader from '../../../../components/domains/DomainHeader';
import AddDomain from '../../../../components/domains/AddDomain';
import DomainSettings from '../../../../components/domains/DomainSettings';
import exportCSV from '../../../../utils/exportcsv';
import exportCSV from '../../../../utils/client/exportcsv';
import Settings from '../../../../components/settings/Settings';
import { useFetchDomains } from '../../../../services/domains';
import { useFetchSCInsight } from '../../../../services/searchConsole';
@@ -34,7 +34,7 @@ const InsightPage: NextPage = () => {
const activDomain: DomainType|null = useMemo(() => {
let active:DomainType|null = null;
if (domainsData?.domains && router.query?.slug) {
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug) || null;
}
return active;
}, [router.query.slug, domainsData]);
@@ -71,7 +71,7 @@ const InsightPage: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>

View File

@@ -1,28 +1,51 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import type { NextPage } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { CSSTransition } from 'react-transition-group';
import toast, { Toaster } from 'react-hot-toast';
import TopBar from '../../components/common/TopBar';
import AddDomain from '../../components/domains/AddDomain';
import Settings from '../../components/settings/Settings';
import { useFetchSettings } from '../../services/settings';
import { useFetchDomains } from '../../services/domains';
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
import DomainItem from '../../components/domains/DomainItem';
import Icon from '../../components/common/Icon';
const SingleDomain: NextPage = () => {
type thumbImages = { [domain:string] : string }
const Domains: NextPage = () => {
const router = useRouter();
const [noScrapprtError, setNoScrapprtError] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAddDomain, setShowAddDomain] = useState(false);
const [domainThumbs, setDomainThumbs] = useState<thumbImages>({});
const { data: appSettings } = useFetchSettings();
const { data: domainsData, isLoading } = useFetchDomains(router, true);
useEffect(() => {
console.log('Domains Data: ', domainsData);
const totalKeywords = useMemo(() => {
let keywords = 0;
if (domainsData?.domains) {
domainsData.domains.forEach(async (domain:DomainType) => {
keywords += domain?.keywordCount || 0;
});
}
return keywords;
}, [domainsData]);
useEffect(() => {
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings?.settings?.screenshot_key) {
domainsData.domains.forEach(async (domain:DomainType) => {
if (domain.domain) {
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.settings.screenshot_key);
if (domainThumb) {
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
}
}
});
}
}, [domainsData, appSettings]);
useEffect(() => {
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
@@ -30,8 +53,20 @@ const SingleDomain: NextPage = () => {
}
}, [appSettings]);
const manuallyUpdateThumb = async (domain: string) => {
if (domain && appSettings?.settings?.screenshot_key) {
const domainThumb = await fetchDomainScreenshot(domain, appSettings.settings.screenshot_key, true);
if (domainThumb) {
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
} else {
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
}
}
};
return (
<div className="Domain ">
<div data-testid="domains" className="Domain flex flex-col min-h-screen">
{noScrapprtError && (
<div className=' p-3 bg-red-600 text-white text-sm text-center'>
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
@@ -44,9 +79,12 @@ const SingleDomain: NextPage = () => {
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0">
<div className='flex justify-between mb-2 items-center'>
<div className=' text-sm'>{domainsData?.domains?.length || 0} Domains</div>
<div className=' text-sm text-gray-600'>
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
</div>
<div>
<button
data-testid="addDomainButton"
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
onClick={() => setShowAddDomain(true)}>
<span
@@ -62,6 +100,8 @@ const SingleDomain: NextPage = () => {
domain={domain}
selected={false}
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
thumb={domainThumbs[domain.domain]}
updateThumb={manuallyUpdateThumb}
// isConsoleIntegrated={false}
/>;
})}
@@ -79,13 +119,17 @@ const SingleDomain: NextPage = () => {
</div>
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
<AddDomain closeModal={() => setShowAddDomain(false)} />
<AddDomain closeModal={() => setShowAddDomain(false)} domains={domainsData?.domains || []} />
</CSSTransition>
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
<Settings closeSettings={() => setShowSettings(false)} />
</CSSTransition>
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span>
</footer>
<Toaster position='bottom-center' containerClassName="react_toaster" />
</div>
);
};
export default SingleDomain;
export default Domains;

View File

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

17
scrapers/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import scrapingAnt from './services/scrapingant';
import scrapingRobot from './services/scrapingrobot';
import serpapi from './services/serpapi';
import serply from './services/serply';
import spaceserp from './services/spaceserp';
import proxy from './services/proxy';
import searchapi from './services/searchapi';
export default [
scrapingRobot,
scrapingAnt,
serpapi,
serply,
spaceserp,
proxy,
searchapi,
];

View File

@@ -0,0 +1,35 @@
import cheerio from 'cheerio';
const proxy:ScraperSettings = {
id: 'proxy',
name: 'Proxy',
website: '',
resultObjectKey: 'data',
headers: () => {
return { Accept: 'gzip,deflate,compress;' };
},
scrapeURL: (keyword) => {
return `https://www.google.com/search?num=100&q=${encodeURI(keyword.keyword)}`;
},
serpExtractor: (content) => {
const extractedResult = [];
const $ = cheerio.load(content);
let lastPosition = 0;
const mainContent = $('body').find('#main');
const children = $(mainContent).find('h3');
for (let index = 0; index < children.length; index += 1) {
const title = $(children[index]).text();
const url = $(children[index]).closest('a').attr('href');
const cleanedURL = url ? url.replaceAll(/^.+?(?=https:|$)/g, '').replaceAll(/(&).*/g, '') : '';
if (title && url) {
lastPosition += 1;
extractedResult.push({ title, url: cleanedURL, position: lastPosition });
}
}
return extractedResult;
},
};
export default proxy;

View File

@@ -0,0 +1,20 @@
const scrapingAnt:ScraperSettings = {
id: 'scrapingant',
name: 'ScrapingAnt',
website: 'scrapingant.com',
headers: (keyword) => {
// eslint-disable-next-line max-len
const mobileAgent = 'Mozilla/5.0 (Linux; Android 10; SM-G996U Build/QP1A.190711.020; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Mobile Safari/537.36';
return keyword && keyword.device === 'mobile' ? { 'Ant-User-Agent': mobileAgent } : {};
},
scrapeURL: (keyword, settings, countryData) => {
const scraperCountries = ['AE', 'BR', 'CN', 'DE', 'ES', 'FR', 'GB', 'HK', 'PL', 'IN', 'IT', 'IL', 'JP', 'NL', 'RU', 'SA', 'US', 'CZ'];
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
const lang = countryData[country][2];
const url = encodeURI(`https://www.google.com/search?num=100&hl=${lang}&q=${keyword.keyword}`);
return `https://api.scrapingant.com/v2/extended?url=${url}&x-api-key=${settings.scaping_api}&proxy_country=${country}&browser=false`;
},
resultObjectKey: 'result',
};
export default scrapingAnt;

View File

@@ -0,0 +1,15 @@
const scrapingRobot:ScraperSettings = {
id: 'scrapingrobot',
name: 'Scraping Robot',
website: 'scrapingrobot.com',
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
const device = keyword.device === 'mobile' ? '&mobile=true' : '';
const lang = countryData[country][2];
const url = encodeURI(`https://www.google.com/search?num=100&hl=${lang}&q=${keyword.keyword}`);
return `https://api.scrapingrobot.com/?token=${settings.scaping_api}&proxyCountry=${country}&render=false${device}&url=${url}`;
},
resultObjectKey: 'result',
};
export default scrapingRobot;

View File

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

View File

@@ -0,0 +1,38 @@
interface SerpApiResult {
title: string,
link: string,
position: number,
}
const serpapi:ScraperSettings = {
id: 'serpapi',
name: 'SerpApi.com',
website: 'serpapi.com',
headers: (keyword, settings) => {
return {
'Content-Type': 'application/json',
'X-API-Key': settings.scaping_api,
};
},
scrapeURL: (keyword, settings) => {
return `https://serpapi.com/search?q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}&api_key=${settings.scaping_api}`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SerpApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerpApiResult[];
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
return extractedResult;
},
};
export default serpapi;

View File

@@ -0,0 +1,42 @@
interface SerplyResult {
title: string,
link: string,
realPosition: number,
}
const scraperCountries = ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'];
const serply:ScraperSettings = {
id: 'serply',
name: 'Serply',
website: 'serply.io',
headers: (keyword, settings) => {
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
return {
'Content-Type': 'application/json',
'X-User-Agent': keyword.device === 'mobile' ? 'mobile' : 'desktop',
'X-Api-Key': settings.scaping_api,
'X-Proxy-Location': country,
};
},
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}`;
},
resultObjectKey: 'result',
serpExtractor: (content) => {
const extractedResult = [];
const results: SerplyResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerplyResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.realPosition,
});
}
}
return extractedResult;
},
};
export default serply;

View File

@@ -0,0 +1,34 @@
interface SpaceSerpResult {
title: string,
link: string,
domain: string,
position: number
}
const spaceSerp:ScraperSettings = {
id: 'spaceSerp',
name: 'Space Serp',
website: 'spaceserp.com',
scrapeURL: (keyword, settings, countryData) => {
const country = keyword.country || 'US';
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}${keyword.device === 'mobile' ? '&device=mobile' : ''}&resultBlocks=`;
},
resultObjectKey: 'organic_results',
serpExtractor: (content) => {
const extractedResult = [];
const results: SpaceSerpResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SpaceSerpResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.position,
});
}
}
return extractedResult;
},
};
export default spaceSerp;

View File

@@ -7,7 +7,7 @@ type UpdatePayload = {
domain: DomainType
}
export async function fetchDomains(router: NextRouter, withStats:boolean) {
export async function fetchDomains(router: NextRouter, withStats:boolean): Promise<{domains: DomainType[]}> {
const res = await fetch(`${window.location.origin}/api/domains${withStats ? '?withstats=true' : ''}`, { method: 'GET' });
if (res.status >= 400 && res.status < 600) {
if (res.status === 401) {
@@ -19,6 +19,36 @@ export async function fetchDomains(router: NextRouter, withStats:boolean) {
return res.json();
}
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
const domainThumbsRaw = localStorage.getItem('domainThumbs');
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
if (!domThumbs[domain] || forceFetch) {
try {
const screenshotURL = `https://image.thum.io/get/auth/${screenshot_key}/maxAge/96/width/200/https://${domain}`;
const domainImageRes = await fetch(screenshotURL);
const domainImageBlob = domainImageRes.status === 200 ? await domainImageRes.blob() : false;
if (domainImageBlob) {
const reader = new FileReader();
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = reject;
reader.readAsDataURL(domainImageBlob);
});
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain]: imageBase }));
return imageBase;
}
return false;
} catch (error) {
return false;
}
} else if (domThumbs[domain]) {
return domThumbs[domain];
}
return false;
}
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
return useQuery('domains', () => fetchDomains(router, withStats));
}
@@ -26,9 +56,9 @@ export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
export function useAddDomain(onSuccess:Function) {
const router = useRouter();
const queryClient = useQueryClient();
return useMutation(async (domainName:string) => {
return useMutation(async (domains:string[]) => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) };
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domains }) };
const res = await fetch(`${window.location.origin}/api/domains`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
@@ -37,11 +67,12 @@ export function useAddDomain(onSuccess:Function) {
}, {
onSuccess: async (data) => {
console.log('Domain Added!!!', data);
const newDomain:DomainType = data.domain;
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
const newDomain:DomainType[] = data.domains;
const singleDomain = newDomain.length === 1;
toast(`${singleDomain ? newDomain[0].domain : `${newDomain.length} domains`} Added Successfully!`, { icon: '✔️' });
onSuccess(false);
if (newDomain && newDomain.slug) {
router.push(`/domain/${data.domain.slug}`);
if (singleDomain) {
router.push(`/domain/${newDomain[0].slug}`);
}
queryClient.invalidateQueries(['domains']);
},

View File

@@ -153,3 +153,23 @@ export function useRefreshKeywords(onSuccess:Function) {
},
});
}
export function useFetchSingleKeyword(keywordID:number) {
return useQuery(['keyword', keywordID], async () => {
try {
const fetchURL = `${window.location.origin}/api/keyword?id=${keywordID}`;
const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json());
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return { history: res.keyword.history || [], searchResult: res.keyword.lastResult || [] };
} catch (error) {
throw new Error('Error Loading Keyword Details');
}
}, {
onError: () => {
console.log('Error Loading Keyword Data!!!');
toast('Error Loading Keyword Details.', { icon: '⚠️' });
},
});
}

View File

@@ -10,7 +10,7 @@ export function useFetchSettings() {
return useQuery('settings', () => fetchSettings());
}
const useUpdateSettings = (onSuccess:Function|undefined) => {
export const useUpdateSettings = (onSuccess:Function|undefined) => {
const queryClient = useQueryClient();
return useMutation(async (settings: SettingsType) => {
@@ -38,4 +38,25 @@ const useUpdateSettings = (onSuccess:Function|undefined) => {
});
};
export default useUpdateSettings;
export function useClearFailedQueue(onSuccess:Function) {
const queryClient = useQueryClient();
return useMutation(async () => {
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
const fetchOpts = { method: 'PUT', headers };
const res = await fetch(`${window.location.origin}/api/clearfailed`, fetchOpts);
if (res.status >= 400 && res.status < 600) {
throw new Error('Bad response from server');
}
return res.json();
}, {
onSuccess: async () => {
onSuccess();
toast('Failed Queue Cleared', { icon: '✔️' });
queryClient.invalidateQueries(['settings']);
},
onError: () => {
console.log('Error Clearing Failed Queue!!!');
toast('Error Clearing Failed Queue.', { icon: '⚠️' });
},
});
}

View File

@@ -84,8 +84,8 @@ body {
.domKeywords_head--alpha_desc .domKeywords_head_keyword::after,
.domKeywords_head--pos_desc .domKeywords_head_position::after,
.domKeywords_head--imp_desc .domKeywords_head_imp::after,
.domKeywords_head--visits_desc .domKeywords_head_visits::after,
.domKeywords_head--imp_asc .domKeywords_head_imp::after,
.domKeywords_head--visits_asc .domKeywords_head_visits::after,
.domKeywords_head--ctr_desc .domKeywords_head_ctr::after {
content: "↓";
display: inline-block;
@@ -98,8 +98,8 @@ body {
.domKeywords_head--alpha_asc .domKeywords_head_keyword::after,
.domKeywords_head--pos_asc .domKeywords_head_position::after,
.domKeywords_head--imp_asc .domKeywords_head_imp::after,
.domKeywords_head--visits_asc .domKeywords_head_visits::after,
.domKeywords_head--imp_desc .domKeywords_head_imp::after,
.domKeywords_head--visits_desc .domKeywords_head_visits::after,
.domKeywords_head--ctr_asc .domKeywords_head_ctr::after {
content: "↑";
display: inline-block;
@@ -282,3 +282,8 @@ body {
right: 240px;
}
}
/* Disable LastPass Icon for Secret Field */
[autocomplete="off"] + div[data-lastpass-icon-root="true"], [autocomplete="off"] + div[data-lastpass-infield="true"] {
display: none;
}

View File

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

24
types.d.ts vendored
View File

@@ -54,7 +54,7 @@ type KeywordFilters = {
}
type countryData = {
[ISO:string] : string[]
[ISO:string] : [countryName:string, cityName:string, language:string]
}
type countryCodeData = {
@@ -78,6 +78,13 @@ type SettingsType = {
smtp_username?: string,
smtp_password?: string,
search_console_integrated?: boolean,
available_scapers?: Array,
scrape_interval?: string,
scrape_delay?: string,
scrape_retry?: boolean,
failed_queue?: string[]
version?: string,
screenshot_key?: string,
}
type KeywordSCDataChild = {
@@ -163,3 +170,18 @@ type SCDomainDataType = {
}
type SCKeywordType = SearchAnalyticsItem;
type scraperExtractedItem = {
title: string,
url: string,
position: number,
}
interface ScraperSettings {
id:string,
name:string,
website:string,
resultObjectKey: string,
headers?(keyword:KeywordType, settings: SettingsType): Object,
scrapeURL?(keyword:KeywordType, settings:SettingsType, countries:countryData): string,
serpExtractor?(content:string): scraperExtractedItem[],
}

View File

@@ -9,16 +9,16 @@ export const SCsortKeywords = (theKeywords:SCKeywordType[], sortBy:string) : SCK
const keywords = theKeywords.map((k) => ({ ...k, position: k.position === 0 ? 111 : k.position }));
switch (sortBy) {
case 'imp_asc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => b.impressions - a.impressions);
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.impressions > b.impressions ? 1 : -1));
break;
case 'imp_desc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => a.impressions - b.impressions);
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.impressions > a.impressions ? 1 : -1));
break;
case 'visits_asc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => b.clicks - a.clicks);
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.clicks > b.clicks ? 1 : -1));
break;
case 'visits_desc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => a.clicks - b.clicks);
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.clicks > a.clicks ? 1 : -1));
break;
case 'ctr_asc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => b.ctr - a.ctr);
@@ -27,17 +27,17 @@ export const SCsortKeywords = (theKeywords:SCKeywordType[], sortBy:string) : SCK
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => a.ctr - b.ctr);
break;
case 'pos_asc':
sortedItems = keywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.position > a.position ? 1 : -1));
sortedItems = keywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.position < a.position ? 1 : -1));
sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position }));
break;
case 'pos_desc':
sortedItems = keywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.position > b.position ? 1 : -1));
sortedItems = keywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.position < b.position ? 1 : -1));
sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position }));
break;
case 'alpha_asc':
case 'alpha_desc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.keyword > a.keyword ? 1 : -1));
break;
case 'alpha_desc':
case 'alpha_asc':
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.keyword > b.keyword ? 1 : -1));
break;
default:

View File

@@ -1,4 +1,4 @@
import countries from './countries';
import countries from '../countries';
/**
* Generates CSV File form the given domain & keywords, and automatically downloads it.

2
utils/client/helpers.ts Normal file
View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export const formattedNum = (num:number) => new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(num);

View File

@@ -28,7 +28,7 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string, scDataTyp
case 'alpha_desc':
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1));
break;
case 'imp_asc':
case 'imp_desc':
if (scDataType) {
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
const bImpressionData = b.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
@@ -37,7 +37,7 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string, scDataTyp
});
}
break;
case 'imp_desc':
case 'imp_asc':
if (scDataType) {
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
const bImpressionData = b.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
@@ -46,21 +46,21 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string, scDataTyp
});
}
break;
case 'visits_asc':
if (scDataType) {
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
const bImpressionData = b.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
const aImpressionData = a.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
return aImpressionData > bImpressionData ? 1 : -1;
});
}
break;
case 'visits_desc':
if (scDataType) {
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
const bImpressionData = b.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
const aImpressionData = a.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
return bImpressionData > aImpressionData ? 1 : -1;
const bVisitsData = b.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
const aVisitsData = a.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
return aVisitsData > bVisitsData ? 1 : -1;
});
}
break;
case 'visits_asc':
if (scDataType) {
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
const bVisitsData = b.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
const aVisitsData = a.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0;
return bVisitsData > aVisitsData ? 1 : -1;
});
}
break;
@@ -98,7 +98,8 @@ export const filterKeywords = (keywords: KeywordType[], filterParams: KeywordFil
const filteredItems:KeywordType[] = [];
keywords.forEach((keywrd) => {
const countryMatch = filterParams.countries.length === 0 ? true : filterParams.countries && filterParams.countries.includes(keywrd.country);
const searchMatch = !filterParams.search ? true : filterParams.search && keywrd.keyword.includes(filterParams.search);
const searchMatch = !filterParams.search ? true : filterParams.search
&& keywrd.keyword.toLowerCase().includes(filterParams.search.toLowerCase());
const tagsMatch = filterParams.tags.length === 0 ? true : filterParams.tags && keywrd.tags.find((x) => filterParams.tags.includes(x));
if (countryMatch && searchMatch && tagsMatch) {

View File

@@ -0,0 +1,35 @@
/* eslint-disable import/prefer-default-export */
export const isValidDomain = (domain:string): boolean => {
if (typeof domain !== 'string') return false;
if (!domain.includes('.')) return false;
let value = domain;
const validHostnameChars = /^[a-zA-Z0-9-.]{1,253}\.?$/g;
if (!validHostnameChars.test(value)) {
return false;
}
if (value.endsWith('.')) {
value = value.slice(0, value.length - 1);
}
if (value.length > 253) {
return false;
}
const labels = value.split('.');
const isValid = labels.every((label) => {
const validLabelChars = /^([a-zA-Z0-9-]+)$/g;
const validLabel = (
validLabelChars.test(label)
&& label.length < 64
&& !label.startsWith('-')
&& !label.endsWith('-')
);
return validLabel;
});
return isValid;
};

View File

@@ -2,6 +2,12 @@ import Keyword from '../database/models/keyword';
import parseKeywords from './parseKeywords';
import { readLocalSCData } from './searchConsole';
/**
* The function `getdomainStats` takes an array of domain objects, retrieves keyword and stats data for
* each domain, and calculates various statistics for each domain.
* @param {DomainType[]} domains - An array of objects of type DomainType.
* @returns {DomainType[]} - An array of objects of type DomainType.
*/
const getdomainStats = async (domains:DomainType[]): Promise<DomainType[]> => {
const finalDomains: DomainType[] = [];
console.log('domains: ', domains.length);
@@ -15,7 +21,8 @@ const getdomainStats = async (domains:DomainType[]): Promise<DomainType[]> => {
domainWithStat.keywordCount = keywords.length;
const keywordPositions = keywords.reduce((acc, itm) => (acc + itm.position), 0);
const KeywordsUpdateDates: number[] = keywords.reduce((acc: number[], itm) => [...acc, new Date(itm.lastUpdated).getTime()], [0]);
domainWithStat.keywordsUpdated = new Date(Math.max(...KeywordsUpdateDates)).toJSON();
const lastKeywordUpdateDate = Math.max(...KeywordsUpdateDates);
domainWithStat.keywordsUpdated = new Date(lastKeywordUpdateDate || new Date(domain.lastUpdated).getTime()).toJSON();
domainWithStat.avgPosition = Math.round(keywordPositions / keywords.length);
// Then Load the SC File and read the stats and calculate the Last 7 days stats

View File

@@ -60,7 +60,10 @@ const getPositionChange = (history:KeywordHistory, position:number) : number =>
}));
const historySorted = historyArray.sort((a, b) => a.date - b.date);
const previousPos = historySorted[historySorted.length - 2].position;
status = previousPos - position;
status = previousPos === 0 ? position : previousPos - position;
if (position === 0 && previousPos > 0) {
status = previousPos - 100;
}
}
return status;
};

View File

@@ -1,4 +1,10 @@
export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks') => {
/**
* The function `sortInsightItems` sorts an array of `SCInsightItem` objects based on a specified property.
* @param {SCInsightItem[]} items - An array of SCInsightItem objects.
* @param {string} [sortBy=clicks] - The `sortBy` parameter determines the property by which the `items` array should be sorted.
* @returns {SCInsightItem[]}
*/
export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks'): SCInsightItem[] => {
const sortKey = sortBy as keyof SCInsightItem;
let sortedItems = [];
switch (sortKey) {
@@ -18,6 +24,13 @@ export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks
return sortedItems;
};
/**
* The `getCountryInsight` function takes search analytics data and returns insights about countries based on clicks, impressions, CTR, and position.
* @param {SCDomainDataType} SCData - The SCData parameter is an object that contains search analytics data for different dates.
* @param {string} [sortBy=clicks] - The "sortBy" parameter is used to specify the sorting criteria for the country insights.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which the search analytics data is retrieved.
* @returns {SCInsightItem[]}
*/
export const getCountryInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const keywordsCounts: { [key:string]: string[] } = {};
const countryItems: { [key:string]: SCInsightItem } = {};
@@ -57,6 +70,13 @@ export const getCountryInsight = (SCData:SCDomainDataType, sortBy:string = 'clic
return sortBy ? sortInsightItems(countryInsight, sortBy) : countryInsight;
};
/**
* The `getKeywordsInsight` function takes search analytics data, sorts it based on specified criteria, and returns insights on keywords.
* @param {SCDomainDataType} SCData - The SCData parameter is of type SCDomainDataType, which is an object containing search analytics data for a specific domain.
* @param {string} [sortBy=clicks] - The "sortBy" parameter is used to specify the sorting criteria for the keyword insights.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which the search analytics data is retrieved.
* @returns {SCInsightItem[]}
*/
export const getKeywordsInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const keywordItems: { [key:string]: SCInsightItem } = {};
const keywordCounts: { [key:string]: number } = {};
@@ -99,6 +119,13 @@ export const getKeywordsInsight = (SCData:SCDomainDataType, sortBy:string = 'cli
return sortBy ? sortInsightItems(keywordInsight, sortBy) : keywordInsight;
};
/**
* The `getPagesInsight` function takes a domain's search analytics data, sorts it based on specified criteria and returns insights about the pages.
* @param {SCDomainDataType} SCData - SCData is an object that contains search analytics data for a specific domain.
* @param {string} [sortBy=clicks] - The `sortBy` parameter is used to specify the sorting criteria for the pages insight.
* @param {string} [queryDate=thirtyDays] - The `queryDate` parameter is a string that represents the date range for which you want to retrieve the data.
* @returns {SCInsightItem[]}
*/
export const getPagesInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
const pagesItems: { [key:string]: SCInsightItem } = {};
const keywordCounts: { [key:string]: number } = {};

View File

@@ -1,32 +1,117 @@
import { performance } from 'perf_hooks';
import { RefreshResult, scrapeKeywordFromGoogle } from './scraper';
import { setTimeout as sleep } from 'timers/promises';
import { RefreshResult, removeFromRetryQueue, retryScrape, scrapeKeywordFromGoogle } from './scraper';
import parseKeywords from './parseKeywords';
import Keyword from '../database/models/keyword';
/**
* Refreshes the Keywords position by Scraping Google Search Result by
* Determining whether the keywords should be scraped in Parallel or not
* @param {KeywordType[]} keywords - Keywords to scrape
* @param {Keyword[]} rawkeyword - Keywords to scrape
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
* @returns {Promise}
*/
const refreshKeywords = async (keywords:KeywordType[], settings:SettingsType): Promise<RefreshResult[]> => {
if (!keywords || keywords.length === 0) { return []; }
const refreshAndUpdateKeywords = async (rawkeyword:Keyword[], settings:SettingsType): Promise<KeywordType[]> => {
const keywords:KeywordType[] = rawkeyword.map((el) => el.get({ plain: true }));
if (!rawkeyword || rawkeyword.length === 0) { return []; }
const start = performance.now();
const updatedKeywords: KeywordType[] = [];
let refreshedResults: RefreshResult[] = [];
if (['scrapingant', 'serpapi'].includes(settings.scraper_type)) {
refreshedResults = await refreshParallel(keywords, settings);
if (['scrapingant', 'serpapi', 'searchapi'].includes(settings.scraper_type)) {
const refreshedResults = await refreshParallel(keywords, settings);
if (refreshedResults.length > 0) {
for (const keyword of rawkeyword) {
const refreshedkeywordData = refreshedResults.find((k) => k && k.ID === keyword.ID);
if (refreshedkeywordData) {
const updatedkeyword = await updateKeywordPosition(keyword, refreshedkeywordData, settings);
updatedKeywords.push(updatedkeyword);
}
}
}
} else {
for (const keyword of keywords) {
for (const keyword of rawkeyword) {
console.log('START SCRAPE: ', keyword.keyword);
const refreshedkeywordData = await scrapeKeywordFromGoogle(keyword, settings);
refreshedResults.push(refreshedkeywordData);
const updatedkeyword = await refreshAndUpdateKeyword(keyword, settings);
updatedKeywords.push(updatedkeyword);
if (keywords.length > 0 && settings.scrape_delay && settings.scrape_delay !== '0') {
await sleep(parseInt(settings.scrape_delay, 10));
}
}
}
const end = performance.now();
console.log(`time taken: ${end - start}ms`);
return refreshedResults;
return updatedKeywords;
};
/**
* Scrape Serp for given keyword and update the position in DB.
* @param {Keyword} keyword - Keywords to scrape
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
* @returns {Promise<KeywordType>}
*/
const refreshAndUpdateKeyword = async (keyword: Keyword, settings: SettingsType): Promise<KeywordType> => {
const currentkeyword = keyword.get({ plain: true });
const refreshedkeywordData = await scrapeKeywordFromGoogle(currentkeyword, settings);
const updatedkeyword = refreshedkeywordData ? await updateKeywordPosition(keyword, refreshedkeywordData, settings) : currentkeyword;
return updatedkeyword;
};
/**
* Processes the scraped data for the given keyword and updates the keyword serp position in DB.
* @param {Keyword} keywordRaw - Keywords to Update
* @param {RefreshResult} udpatedkeyword - scraped Data for that Keyword
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
* @returns {Promise<KeywordType>}
*/
export const updateKeywordPosition = async (keywordRaw:Keyword, udpatedkeyword: RefreshResult, settings: SettingsType): Promise<KeywordType> => {
const keywordPrased = parseKeywords([keywordRaw.get({ plain: true })]);
const keyword = keywordPrased[0];
// const udpatedkeyword = refreshed;
let updated = keyword;
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;
const updatedVal = {
position: newPosition,
updating: false,
url: udpatedkeyword.url,
lastResult: udpatedkeyword.result,
history,
lastUpdated: udpatedkeyword.error ? keyword.lastUpdated : theDate.toJSON(),
lastUpdateError: udpatedkeyword.error
? JSON.stringify({ date: theDate.toJSON(), error: `${udpatedkeyword.error}`, scraper: settings.scraper_type })
: 'false',
};
// If failed, Add to Retry Queue Cron
if (udpatedkeyword.error && settings?.scrape_retry) {
await retryScrape(keyword.ID);
} else {
await removeFromRetryQueue(keyword.ID);
}
// Update the Keyword Position in Database
try {
await keywordRaw.update({
...updatedVal,
lastResult: Array.isArray(udpatedkeyword.result) ? JSON.stringify(udpatedkeyword.result) : udpatedkeyword.result,
history: JSON.stringify(history),
});
console.log('[SUCCESS] Updating the Keyword: ', keyword.keyword);
updated = { ...keyword, ...updatedVal, lastUpdateError: JSON.parse(updatedVal.lastUpdateError) };
} catch (error) {
console.log('[ERROR] Updating SERP for Keyword', keyword.keyword, error);
}
}
return updated;
};
/**
@@ -49,4 +134,4 @@ const refreshParallel = async (keywords:KeywordType[], settings:SettingsType) :
});
};
export default refreshKeywords;
export default refreshAndUpdateKeywords;

View File

@@ -1,10 +1,9 @@
import axios, { AxiosResponse, CreateAxiosDefaults } from 'axios';
// import axiosRetry from 'axios-retry';
// import path from 'path';
import cheerio from 'cheerio';
import { readFile, writeFile } from 'fs/promises';
import HttpsProxyAgent from 'https-proxy-agent';
import countries from './countries';
import allScrapers from '../scrapers/index';
type SearchResult = {
title: string,
@@ -13,38 +12,26 @@ type SearchResult = {
}
type SERPObject = {
postion:number|boolean,
postion:number,
url:string
}
export type RefreshResult = false | {
ID: number,
keyword: string,
position:number | boolean,
position:number,
url: string,
result: SearchResult[],
error?: boolean | string
}
interface SerplyResult {
title: string,
link: string,
realPosition: number,
}
interface SerpApiResult {
title: string,
link: string,
position: number,
}
/**
* Creates a SERP Scraper client promise based on the app settings.
* @param {KeywordType} keyword - the keyword to get the SERP for.
* @param {SettingsType} settings - the App Settings that contains the scraper details
* @returns {Promise}
*/
export const getScraperClient = (keyword:KeywordType, settings:SettingsType): Promise<AxiosResponse|Response> | false => {
export const getScraperClient = (keyword:KeywordType, settings:SettingsType, scraper?: ScraperSettings): Promise<AxiosResponse|Response> | false => {
let apiURL = ''; let client: Promise<AxiosResponse|Response> | false = false;
const headers: any = {
'Content-Type': 'application/json',
@@ -58,40 +45,27 @@ export const getScraperClient = (keyword:KeywordType, settings:SettingsType): Pr
headers['User-Agent'] = mobileAgent;
}
if (settings && settings.scraper_type === 'scrapingant' && settings.scaping_api) {
const scraperCountries = ['AE', 'BR', 'CN', 'DE', 'ES', 'FR', 'GB', 'HK', 'PL', 'IN', 'IT', 'IL', 'JP', 'NL', 'RU', 'SA', 'US', 'CZ'];
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
const lang = countries[country][2];
apiURL = `https://api.scrapingant.com/v2/extended?url=https%3A%2F%2Fwww.google.com%2Fsearch%3Fnum%3D100%26hl%3D${lang}%26q%3D${encodeURI(keyword.keyword)}&x-api-key=${settings.scaping_api}&proxy_country=${country}&browser=false`;
}
if (settings && settings.scraper_type === 'scrapingrobot' && settings.scaping_api) {
const country = keyword.country || 'US';
const lang = countries[country][2];
apiURL = `https://api.scrapingrobot.com/?token=${settings.scaping_api}&proxyCountry=${country}&render=false${keyword.device === 'mobile' ? '&mobile=true' : ''}&url=https%3A%2F%2Fwww.google.com%2Fsearch%3Fnum%3D100%26hl%3D${lang}%26q%3D${encodeURI(keyword.keyword)}`;
}
// Serply.io docs https://docs.serply.io/api
if (settings && settings.scraper_type === 'serply' && settings.scaping_api) {
const scraperCountries = ['US', 'CA', 'IE', 'GB', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR'];
const country = scraperCountries.includes(keyword.country.toUpperCase()) ? keyword.country : 'US';
if (keyword.device === 'mobile') {
headers['X-User-Agent'] = 'mobile';
} else {
headers['X-User-Agent'] = 'desktop';
if (scraper) {
// Set Scraper Header
const scrapeHeaders = scraper.headers ? scraper.headers(keyword, settings) : null;
const scraperAPIURL = scraper.scrapeURL ? scraper.scrapeURL(keyword, settings, countries) : null;
if (scrapeHeaders && Object.keys(scrapeHeaders).length > 0) {
Object.keys(scrapeHeaders).forEach((headerItemKey:string) => {
headers[headerItemKey] = scrapeHeaders[headerItemKey as keyof object];
});
}
// Set Scraper API URL
// If not URL is generated, stop right here.
if (scraperAPIURL) {
apiURL = scraperAPIURL;
} else {
return false;
}
headers['X-Proxy-Location'] = country;
headers['X-Api-Key'] = settings.scaping_api;
apiURL = `https://api.serply.io/v1/search/q=${encodeURI(keyword.keyword)}&num=100&hl=${country}`;
}
// SerpApi docs: https://serpapi.com
if (settings && settings.scraper_type === 'serpapi' && settings.scaping_api) {
apiURL = `https://serpapi.com/search?q=${encodeURI(keyword.keyword)}&num=100&gl=${keyword.country}&device=${keyword.device}&api_key=${settings.scaping_api}`;
}
if (settings && settings.scraper_type === 'proxy' && settings.proxy) {
const axiosConfig: CreateAxiosDefaults = {};
headers.Accept = 'gzip,deflate,compress;';
axiosConfig.headers = headers;
const proxies = settings.proxy.split(/\r?\n|\r|\n/g);
let proxyURL = '';
@@ -128,30 +102,37 @@ export const scrapeKeywordFromGoogle = async (keyword:KeywordType, settings:Sett
result: keyword.lastResult,
error: true,
};
const scraperClient = getScraperClient(keyword, settings);
const scraperType = settings?.scraper_type || '';
const scraperObj = allScrapers.find((scraper:ScraperSettings) => scraper.id === scraperType);
const scraperClient = getScraperClient(keyword, settings, scraperObj);
if (!scraperClient) { return false; }
let res:any = null; let scraperError:any = null;
try {
if (settings && settings.scraper_type === 'proxy' && settings.proxy) {
res = await scraperClient;
} else {
res = await scraperClient.then((result:any) => result.json());
}
if (res && (res.data || res.html || res.result || res.results || res.organic_results)) {
const extracted = extractScrapedResult(res.data || res.html || res.result || res.results || res.organic_results, settings.scraper_type);
// await writeFile('result.txt', JSON.stringify(extracted), { encoding: 'utf-8' }).catch((err) => { console.log(err); });
let scraperError:any = null;
try {
const res = scraperType === 'proxy' && settings.proxy ? await scraperClient : await scraperClient.then((reslt:any) => reslt.json());
const scraperResult = scraperObj?.resultObjectKey && res[scraperObj.resultObjectKey] ? res[scraperObj.resultObjectKey] : '';
const scrapeResult:string = (res.data || res.html || res.results || scraperResult || '');
if (res && scrapeResult) {
const extracted = scraperObj?.serpExtractor ? scraperObj.serpExtractor(scrapeResult) : extractScrapedResult(scrapeResult, keyword.device);
// await writeFile('result.txt', JSON.stringify(scrapeResult), { encoding: 'utf-8' }).catch((err) => { console.log(err); });
const serp = getSerp(keyword.domain, extracted);
refreshedResults = { ID: keyword.ID, keyword: keyword.keyword, position: serp.postion, url: serp.url, result: extracted, error: false };
console.log('SERP: ', keyword.keyword, serp.postion, serp.url);
console.log('[SERP]: ', keyword.keyword, serp.postion, serp.url);
} else {
scraperError = res.detail || res.error || 'Unknown Error';
throw new Error(res);
}
} catch (error:any) {
console.log('#### SCRAPE ERROR: ', keyword.keyword, '. Error: ', scraperError);
refreshedResults.error = scraperError;
if (settings.scraper_type === 'proxy' && error && error.response && error.response.statusText) {
refreshedResults.error = `[${error.response.status}] ${error.response.statusText}`;
}
console.log('[ERROR] Scraping Keyword : ', keyword.keyword, '. Error: ', error && error.response && error.response.statusText);
if (!(error && error.response && error.response.statusText)) {
console.log('[ERROR_MESSAGE]: ', error);
}
}
return refreshedResults;
@@ -160,10 +141,10 @@ export const scrapeKeywordFromGoogle = async (keyword:KeywordType, settings:Sett
/**
* Extracts the Google Search result as object array from the Google Search's HTML content
* @param {string} content - scraped google search page html data.
* @param {string} scraper_type - the type of scraper (Proxy or Scraper)
* @param {string} device - The device of the keyword.
* @returns {SearchResult[]}
*/
export const extractScrapedResult = (content: string, scraper_type:string): SearchResult[] => {
export const extractScrapedResult = (content: string, device: string): SearchResult[] => {
const extractedResult = [];
const $ = cheerio.load(content);
@@ -171,56 +152,34 @@ export const extractScrapedResult = (content: string, scraper_type:string): Sear
const searchResult = hasNumberofResult.children();
let lastPosition = 0;
if (scraper_type === 'proxy') {
const mainContent = $('body').find('#main');
const children = $(mainContent).find('h3');
for (let index = 0; index < children.length; index += 1) {
const title = $(children[index]).text();
const url = $(children[index]).closest('a').attr('href');
const cleanedURL = url ? url.replace('/url?q=', '').replace(/&sa=.*/, '') : '';
for (let i = 0; i < searchResult.length; i += 1) {
if (searchResult[i]) {
const title = $(searchResult[i]).find('h3').html();
const url = $(searchResult[i]).find('a').attr('href');
// console.log(i, url?.slice(0, 40), title?.slice(0, 40));
if (title && url) {
lastPosition += 1;
extractedResult.push({ title, url: cleanedURL, position: lastPosition });
extractedResult.push({ title, url, position: lastPosition });
}
}
} else if (scraper_type === 'serply') {
// results already in json
const results: SerplyResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerplyResult[];
for (const result of results) {
if (result.title && result.link) {
extractedResult.push({
title: result.title,
url: result.link,
position: result.realPosition,
});
}
}
} else if (scraper_type === 'serpapi') {
// results already in json
const results: SerpApiResult[] = (typeof content === 'string') ? JSON.parse(content) : content as SerpApiResult[];
}
for (const { link, title, position } of results) {
if (title && link) {
extractedResult.push({
title,
url: link,
position,
});
}
}
} else {
for (let i = 0; i < searchResult.length; i += 1) {
if (searchResult[i]) {
const title = $(searchResult[i]).find('h3').html();
const url = $(searchResult[i]).find('a').attr('href');
// console.log(i, url?.slice(0, 40), title?.slice(0, 40));
// Mobile Scraper
if (extractedResult.length === 0 && device === 'mobile') {
const items = $('body').find('#rso > div');
for (let i = 0; i < items.length; i += 1) {
const item = $(items[i]);
const linkDom = item.find('a[role="presentation"]');
if (linkDom) {
const url = linkDom.attr('href');
const titleDom = linkDom.find('[role="link"]');
const title = titleDom ? titleDom.text() : '';
if (title && url) {
lastPosition += 1;
extractedResult.push({ title, url, position: lastPosition });
}
}
}
}
}
return extractedResult;
@@ -233,7 +192,7 @@ export const extractScrapedResult = (content: string, scraper_type:string): Sear
* @returns {SERPObject}
*/
export const getSerp = (domain:string, result:SearchResult[]) : SERPObject => {
if (result.length === 0 || !domain) { return { postion: false, url: '' }; }
if (result.length === 0 || !domain) { return { postion: 0, url: '' }; }
const foundItem = result.find((item) => {
const itemDomain = item.url.replace('www.', '').match(/^(?:https?:)?(?:\/\/)?([^/?]+)/i);
return itemDomain && itemDomain.includes(domain.replace('www.', ''));
@@ -248,15 +207,15 @@ export const getSerp = (domain:string, result:SearchResult[]) : SERPObject => {
* @returns {void}
*/
export const retryScrape = async (keywordID: number) : Promise<void> => {
if (!keywordID) { return; }
if (!keywordID && !Number.isInteger(keywordID)) { return; }
let currentQueue: number[] = [];
const filePath = `${process.cwd()}/data/failed_queue.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
currentQueue = JSON.parse(currentQueueRaw);
currentQueue = currentQueueRaw ? JSON.parse(currentQueueRaw) : [];
if (!currentQueue.includes(keywordID)) {
currentQueue.push(keywordID);
currentQueue.push(Math.abs(keywordID));
}
await writeFile(filePath, JSON.stringify(currentQueue), { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
@@ -268,13 +227,13 @@ export const retryScrape = async (keywordID: number) : Promise<void> => {
* @returns {void}
*/
export const removeFromRetryQueue = async (keywordID: number) : Promise<void> => {
if (!keywordID) { return; }
if (!keywordID && !Number.isInteger(keywordID)) { return; }
let currentQueue: number[] = [];
const filePath = `${process.cwd()}/data/failed_queue.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
currentQueue = JSON.parse(currentQueueRaw);
currentQueue = currentQueue.filter((item) => item !== keywordID);
currentQueue = currentQueueRaw ? JSON.parse(currentQueueRaw) : [];
currentQueue = currentQueue.filter((item) => item !== Math.abs(keywordID));
await writeFile(filePath, JSON.stringify(currentQueue), { encoding: 'utf-8' }).catch((err) => { console.log(err); return '[]'; });
};

View File

@@ -1,12 +1,21 @@
import { auth, searchconsole_v1 } from '@googleapis/searchconsole';
import { readFile, writeFile } from 'fs/promises';
import { readFile, writeFile, unlink } from 'fs/promises';
import { getCountryCodeFromAlphaThree } from './countries';
export type SCDomainFetchError = {
error: boolean,
errorMsg: string,
}
type fetchConsoleDataResponse = SearchAnalyticsItem[] | SearchAnalyticsStat[] | SCDomainFetchError;
/**
* function that retrieves data from the Google Search Console API based on the provided domain name, number of days, and optional type.
* @param {string} domainName - The domain name for which you want to fetch search console data.
* @param {number} days - The `days` parameter is the number of days of data you want to fetch from the Search Console.
* @param {string} [type] - The `type` parameter is an optional parameter that specifies the type of data to fetch from the Search Console API.
* @returns {Promise<fetchConsoleDataResponse>}
*/
const fetchSearchConsoleData = async (domainName:string, days:number, type?:string): Promise<fetchConsoleDataResponse> => {
if (!domainName) return { error: true, errorMsg: 'Domain Not Provided!' };
try {
@@ -54,7 +63,7 @@ const fetchSearchConsoleData = async (domainName:string, days:number, type?:stri
date: row.keys[0],
clicks: row.clicks,
impressions: row.impressions,
ctr: row.ctr,
ctr: row.ctr * 100,
position: row.position,
});
});
@@ -68,6 +77,13 @@ const fetchSearchConsoleData = async (domainName:string, days:number, type?:stri
}
};
/**
* The function fetches search console data for a given domain and returns it in a structured format.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which we
* want to fetch search console data.
* @returns The function `fetchDomainSCData` is returning a Promise that resolves to an object of type
* `SCDomainDataType`.
*/
export const fetchDomainSCData = async (domain:string): Promise<SCDomainDataType> => {
const days = [3, 7, 30];
const scDomainData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '', stats: [] };
@@ -93,6 +109,12 @@ export const fetchDomainSCData = async (domain:string): Promise<SCDomainDataType
return scDomainData;
};
/**
* The function takes a raw search console item and a domain name as input and returns a parsed search analytics item.
* @param {SearchAnalyticsRawItem} SCItem - The SCItem parameter is an object that represents a raw item from the Search Console API.
* @param {string} domainName - The `domainName` parameter is a string that represents the domain name of the website.
* @returns {SearchAnalyticsItem}.
*/
export const parseSearchConsoleItem = (SCItem: SearchAnalyticsRawItem, domainName: string): SearchAnalyticsItem => {
const { clicks = 0, impressions = 0, ctr = 0, position = 0 } = SCItem;
const keyword = SCItem.keys[0];
@@ -101,9 +123,15 @@ export const parseSearchConsoleItem = (SCItem: SearchAnalyticsRawItem, domainNam
const page = SCItem.keys[3] ? SCItem.keys[3].replace('https://', '').replace('http://', '').replace('www', '').replace(domainName, '') : '';
const uid = `${country.toLowerCase()}:${device}:${keyword.replaceAll(' ', '_')}`;
return { keyword, uid, device, country, clicks, impressions, ctr, position, page };
return { keyword, uid, device, country, clicks, impressions, ctr: ctr * 100, position, page };
};
/**
* The function integrates search console data with a keyword object and returns the updated keyword object with the search console data.
* @param {KeywordType} keyword - The `keyword` parameter is of type `KeywordType`, which is a custom type representing a keyword.
* @param {SCDomainDataType} SCData - SCData is an object that contains search analytics data for different time periods
* @returns {KeywordType}
*/
export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainDataType) : KeywordType => {
const kuid = `${keyword.country.toLowerCase()}:${keyword.device}:${keyword.keyword.replaceAll(' ', '_')}`;
const impressions:any = { yesterday: 0, threeDays: 0, sevenDays: 0, thirtyDays: 0, avgSevenDays: 0, avgThreeDays: 0, avgThirtyDays: 0 };
@@ -136,6 +164,11 @@ export const integrateKeywordSCData = (keyword: KeywordType, SCData:SCDomainData
return { ...keyword, scData: finalSCData };
};
/**
* The function reads and returns the domain-specific data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data is being read.
* @returns {Promise<SCDomainDataType>}
*/
export const readLocalSCData = async (domain:string): Promise<SCDomainDataType> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
const currentQueueRaw = await readFile(filePath, { encoding: 'utf-8' }).catch(async () => { await updateLocalSCData(domain); return '{}'; });
@@ -143,6 +176,12 @@ export const readLocalSCData = async (domain:string): Promise<SCDomainDataType>
return domainSCData;
};
/**
* The function reads and returns the domain-specific data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data will be written.
* @param {SCDomainDataType} scDomainData - an object that contains search analytics data for different time periods.
* @returns {Promise<SCDomainDataType|false>}
*/
export const updateLocalSCData = async (domain:string, scDomainData?:SCDomainDataType): Promise<SCDomainDataType|false> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
const emptyData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '' };
@@ -150,4 +189,19 @@ export const updateLocalSCData = async (domain:string, scDomainData?:SCDomainDat
return scDomainData || emptyData;
};
/**
* The function removes the domain-specific Seach Console data stored in a local JSON file.
* @param {string} domain - The `domain` parameter is a string that represents the domain for which the SC data file will be removed.
* @returns {Promise<boolean>} - Returns true if file was removed, else returns false.
*/
export const removeLocalSCData = async (domain:string): Promise<boolean> => {
const filePath = `${process.cwd()}/data/SC_${domain}.json`;
try {
await unlink(filePath);
return true;
} catch (error) {
return false;
}
};
export default fetchSearchConsoleData;