mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c870250fbd | ||
|
|
da92f11afa | ||
|
|
9b9b74af4c | ||
|
|
291aa60bbb | ||
|
|
8a35e358e6 | ||
|
|
f164b287be | ||
|
|
97dd0b131b | ||
|
|
454454a422 | ||
|
|
4620f11c4b | ||
|
|
7ab435ed8b | ||
|
|
9feff13f18 | ||
|
|
f57bca23da | ||
|
|
392122a710 | ||
|
|
fc183d246d | ||
|
|
994afbcedb | ||
|
|
d3d336fa71 | ||
|
|
6f34d64fd5 | ||
|
|
a0014c7650 | ||
|
|
dc3c7a722b | ||
|
|
8a949ce4c0 | ||
|
|
312d12f589 | ||
|
|
be80ed7ef3 | ||
|
|
4748ffc382 | ||
|
|
c0470cfa9d | ||
|
|
1d6b2be95a | ||
|
|
0d846b29f1 | ||
|
|
3b96dab9cc | ||
|
|
0a83924ffe | ||
|
|
d9505158c4 | ||
|
|
9757fde02e | ||
|
|
0538a8c016 | ||
|
|
cace34f39a | ||
|
|
dce7c412e8 | ||
|
|
e61dfb5b90 | ||
|
|
b9d58a721d | ||
|
|
b83df5f3db | ||
|
|
3b6d034d6f | ||
|
|
5dd366b91e | ||
|
|
5fc1779783 | ||
|
|
c5af94a146 | ||
|
|
c406588953 | ||
|
|
3c48d130b6 | ||
|
|
99dbbd1dd9 | ||
|
|
acc0b39d80 | ||
|
|
a1108d240e | ||
|
|
d9e0d0107a | ||
|
|
9e9dad7631 | ||
|
|
8139e399c1 | ||
|
|
cb24696a1f | ||
|
|
b50733defc | ||
|
|
0c8b457eee | ||
|
|
123ad81dae | ||
|
|
6c48b87e5e | ||
|
|
cf8b2c6913 | ||
|
|
6d6e2f63d0 | ||
|
|
74c9603293 | ||
|
|
be5ec96699 | ||
|
|
0fdb43c0a5 | ||
|
|
e904afdd33 | ||
|
|
ee32435d05 | ||
|
|
6a1f1d4adf | ||
|
|
671f89e492 | ||
|
|
9b71f8400b | ||
|
|
ca0dcfc492 | ||
|
|
b0bfba4404 | ||
|
|
b740ef337b | ||
|
|
e3bd5b9c07 | ||
|
|
89824ece23 | ||
|
|
c2b63280cb | ||
|
|
1ebf7aef4b | ||
|
|
457f55353f | ||
|
|
49b4769528 | ||
|
|
35aae7b7e2 | ||
|
|
ad6a354cb9 | ||
|
|
6ac6c23168 | ||
|
|
f00006371d | ||
|
|
a6af9d3475 | ||
|
|
480767deb2 | ||
|
|
62e0fba311 | ||
|
|
6fb862e2a3 | ||
|
|
612cd8d6b3 | ||
|
|
1dfc01673e | ||
|
|
329ed24db8 | ||
|
|
dc8676a027 | ||
|
|
a3362bc6ad | ||
|
|
1e8b4b6bf6 | ||
|
|
02a2d5fec2 | ||
|
|
1711a10b1f | ||
|
|
a42b69bd2b | ||
|
|
7dfb4f99f8 | ||
|
|
d22992bf64 | ||
|
|
b450540d95 | ||
|
|
8688f323a5 | ||
|
|
e9d7730ae7 | ||
|
|
a59903551e | ||
|
|
2e85529183 | ||
|
|
57182f17f6 | ||
|
|
d6da18fb01 | ||
|
|
dd6a801ffd | ||
|
|
e1799fb2f3 | ||
|
|
c8ee418822 | ||
|
|
e7ab7d2db2 | ||
|
|
efb565ba00 | ||
|
|
a11b0f223c | ||
|
|
e6136db742 | ||
|
|
f51380442b |
22
.stylelintrc.json
Normal file
22
.stylelintrc.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"indentation": 3,
|
||||
"declaration-block-single-line-max-declarations": 2,
|
||||
"selector-class-pattern":null,
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"variants",
|
||||
"responsive",
|
||||
"screen"
|
||||
]
|
||||
}
|
||||
],
|
||||
"declaration-block-trailing-semicolon": null,
|
||||
"no-descending-specificity": null
|
||||
}
|
||||
}
|
||||
159
CHANGELOG.md
159
CHANGELOG.md
@@ -2,6 +2,165 @@
|
||||
|
||||
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.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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Adds better error logging for debugging issues ([9b71f84](https://github.com/towfiqi/serpbear/commit/9b71f8400bc17b75722b93cbe745543f6b30814a))
|
||||
* Highlights tracked keywords in Discovery tab ([ee32435](https://github.com/towfiqi/serpbear/commit/ee32435d05c2a2ec6d446cd00e28058f07eb1ad4))
|
||||
* Integrate SerpApi ([ad6a354](https://github.com/towfiqi/serpbear/commit/ad6a354cb93bc6584d71dd1216a8a03d8dba505b))
|
||||
* integrates Google Search Console. ([49b4769](https://github.com/towfiqi/serpbear/commit/49b4769528d18e34c16386b73dfb662e7a9f45a0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Ability to add SMTP without user/pass. ([671f89e](https://github.com/towfiqi/serpbear/commit/671f89e492b0f45d63ae7575c7d4970252c11296)), closes [#30](https://github.com/towfiqi/serpbear/issues/30)
|
||||
* backend error on addind new domain ([c2b6328](https://github.com/towfiqi/serpbear/commit/c2b63280cb9d66b565dc51eb69ee960710ace895))
|
||||
* Backend error on loading the domains page. ([e3bd5b9](https://github.com/towfiqi/serpbear/commit/e3bd5b9c0735939c6b06e9762a3ad041b8b05d6e))
|
||||
* Email Notification was not being sent. ([0fdb43c](https://github.com/towfiqi/serpbear/commit/0fdb43c0a53460cd35daabc4703d26cb11db9601))
|
||||
* Fixes Docker Deployment failure after the SC integration. ([b0bfba4](https://github.com/towfiqi/serpbear/commit/b0bfba440464f8fc7c31609c202e01416a41702d))
|
||||
* hides Search Console Stats if its not connected ([b740ef3](https://github.com/towfiqi/serpbear/commit/b740ef337bbfb43f63528cac891d4cb254318dc7))
|
||||
* Keyword Detail View's broken Search Result ([6a1f1d4](https://github.com/towfiqi/serpbear/commit/6a1f1d4adff89fc718c0f2ffe52a59ab15ad6c80))
|
||||
* Minor UI Issues. ([89824ec](https://github.com/towfiqi/serpbear/commit/89824ece2349b510fa0b7d87b33cacd2c88efc95))
|
||||
|
||||
### [0.1.7](https://github.com/towfiqi/serpbear/compare/v0.1.6...v0.1.7) (2022-12-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Email notifcations now sent everyday at 3am ([f000063](https://github.com/towfiqi/serpbear/commit/f00006371d56c509eae00a72e164658c84fecd00))
|
||||
* shortens hours and minutes in notif emails ([480767d](https://github.com/towfiqi/serpbear/commit/480767deb24072f9e250e4dd7bd3d710c4b6046c))
|
||||
* Throws better error logs in cron for debugging ([a6af9d3](https://github.com/towfiqi/serpbear/commit/a6af9d347544f847e512c4ae55b14c640a897240))
|
||||
|
||||
### [0.1.6](https://github.com/towfiqi/serpbear/compare/v0.1.5...v0.1.6) (2022-12-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CSS Linter issues. ([a599035](https://github.com/towfiqi/serpbear/commit/a59903551eccb3f03f2bc026673bbf9fd0d4bc1e))
|
||||
* invalid json markup ([e9d7730](https://github.com/towfiqi/serpbear/commit/e9d7730ae7ec647d333713248b271bae8693e77b))
|
||||
* Sort was buggy for keyword with >100 position ([d22992b](https://github.com/towfiqi/serpbear/commit/d22992bf6489b11002faba60fa06b5c467867c8b)), closes [#23](https://github.com/towfiqi/serpbear/issues/23)
|
||||
* **UI:** Adds tooltip for Domain action icons. ([b450540](https://github.com/towfiqi/serpbear/commit/b450540d9593d022c94708c9679b5bf7c0279c50))
|
||||
|
||||
### [0.1.5](https://github.com/towfiqi/serpbear/compare/v0.1.4...v0.1.5) (2022-12-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* keyword not in first 100 now shows >100 ([e1799fb](https://github.com/towfiqi/serpbear/commit/e1799fb2f35ab8c0f65eb90e66dcda10b8cb6f16))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* domains with - were not loading the keywords. ([efb565b](https://github.com/towfiqi/serpbear/commit/efb565ba0086d1b3e69ea71456a892ca254856f7)), closes [#11](https://github.com/towfiqi/serpbear/issues/11)
|
||||
* failed scrape messes up lastResult data in db ([dd6a801](https://github.com/towfiqi/serpbear/commit/dd6a801ffda3eacda957dd20d2c97fb6197fbdc2))
|
||||
* First search result items were being skipped. ([d6da18f](https://github.com/towfiqi/serpbear/commit/d6da18fb0135e23dd869d1fb500e12ee2e782bfa)), closes [#13](https://github.com/towfiqi/serpbear/issues/13)
|
||||
* removes empty spaces when adding domain. ([a11b0f2](https://github.com/towfiqi/serpbear/commit/a11b0f223c0647537ab23564df1d2f0b29eef4ae))
|
||||
|
||||
### [0.1.4](https://github.com/towfiqi/serpbear/compare/v0.1.3...v0.1.4) (2022-12-01)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ COPY . .
|
||||
FROM node:lts-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app ./
|
||||
RUN rm -rf /app/data
|
||||
RUN rm -rf /app/__test__
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -29,7 +31,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
|
||||
RUN npm i cryptr dotenv croner @googleapis/searchconsole
|
||||
RUN npm i -g concurrently
|
||||
|
||||
USER nextjs
|
||||
|
||||
48
README.md
48
README.md
@@ -1,32 +1,35 @@
|
||||

|
||||
# SerpBear
|
||||
|
||||
   
|
||||
|
||||
#### [Documentation](https://docs.serpbear.com/) | [Changelog](https://github.com/towfiqi/serpbear/blob/main/CHANGELOG.md) | [Docker Image](https://hub.docker.com/r/towfiqi/serpbear)
|
||||
|
||||
SerpBear is an Open Source Search Engine Position Tracking App. It allows you to track your website's keyword positions in Google and get notified of their positions.
|
||||
|
||||
#### [Documentation](https://docs.serpbear.com/)
|
||||

|
||||
|
||||

|
||||
|
||||
**Features**
|
||||
- **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
|
||||
- **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
|
||||
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools.
|
||||
- **Mobile App:** Add the PWA app to your mobile for a better mobile experience.
|
||||
- **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free.
|
||||
#### Features
|
||||
- **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP.
|
||||
- **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email.
|
||||
- **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools.
|
||||
- **Google Search Console Integration:** Get the actual visit count, impressions & more for Each keyword.
|
||||
- **Mobile App:** Add the PWA app to your mobile for a better mobile experience.
|
||||
- **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 or Your given Proxy ips to scrape google search results to see if your domain appears in the search result for the given keyword.
|
||||
The App uses third party website scrapers like ScrapingAnt, ScrapingRobot, SearchApi, SerpApi 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.
|
||||
- **Step 2:** Access your App and Login.
|
||||
- **Step 3:** Add your First domain.
|
||||
- **Step 4:** Get an free API key from either ScrapingAnt or ScrapingRobot. Skip if you want to use Proxy ips.
|
||||
- **Step 5:** Setup the Scraping API/Proxy from the App's Settings interface.
|
||||
- **Step 6:** Add your keywords and start tracking.
|
||||
- **Step 7:** Optional. From the Settings panel, setup SMTP details to get notified of your keywords positions through email. You can use ElasticEmail and Sendpulse SMTP services that are free.
|
||||
- **Step 1:** Deploy & Run the App.
|
||||
- **Step 2:** Access your App and Login.
|
||||
- **Step 3:** Add your First domain.
|
||||
- **Step 4:** Get an free API key from either ScrapingAnt or ScrapingRobot. Skip if you want to use Proxy ips.
|
||||
- **Step 5:** Setup the Scraping API/Proxy from the App's Settings interface.
|
||||
- **Step 6:** Add your keywords and start tracking.
|
||||
- **Step 7:** Optional. From the Settings panel, setup SMTP details to get notified of your keywords positions through email. You can use ElasticEmail and Sendpulse SMTP services that are free.
|
||||
|
||||
**Compare SerpBear with other SERP tracking services:**
|
||||
#### Compare SerpBear with other SERP tracking services
|
||||
|
||||
|Service | Cost | SERP Lookup | API |
|
||||
|--|--|--|--|
|
||||
@@ -35,9 +38,14 @@ The App uses third party website scrapers like ScrapingAnt, ScrapingRobot or You
|
||||
| SerpWatch.io | $29/mo | 7500/mo | Yes |
|
||||
| Serpwatcher.com | $49/mo| 3000/mo | No |
|
||||
| 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.
|
||||
|
||||
**Stack**
|
||||
- Next.js for Frontend & Backend.
|
||||
- Sqlite for Database.
|
||||
- Next.js for Frontend & Backend.
|
||||
- Sqlite for Database.
|
||||
|
||||
@@ -6,19 +6,20 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T
|
||||
|
||||
type ChartProps ={
|
||||
labels: string[],
|
||||
sreies: number[]
|
||||
sreies: number[],
|
||||
reverse? : boolean,
|
||||
}
|
||||
|
||||
const Chart = ({ labels, sreies }:ChartProps) => {
|
||||
const Chart = ({ labels, sreies, reverse = true }:ChartProps) => {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
scales: {
|
||||
y: {
|
||||
reverse: true,
|
||||
reverse,
|
||||
min: 1,
|
||||
max: 100,
|
||||
max: reverse ? 100 : undefined,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -186,6 +186,65 @@ const Icon = ({ type, color = 'currentColor', size = 16, title = '', classes = '
|
||||
<path d="M5.5 5h13a1 1 0 0 1 .5 1.5L14 12v7l-4-3v-4L5 6.5A1 1 0 0 1 5.5 5" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
}
|
||||
{type === 'idea'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<g fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12h1m8-9v1m8 8h1M5.6 5.6l.7.7m12.1-.7l-.7.7"/>
|
||||
<path d="M9 16a5 5 0 1 1 6 0a3.5 3.5 0 0 0-1 3a2 2 0 0 1-4 0a3.5 3.5 0 0 0-1-3"/>
|
||||
<path d="M9.7 17h4.6"/>
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
{type === 'tracking'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path fill={color} d="M21 7a.78.78 0 0 0 0-.21a.64.64 0 0 0-.05-.17a1.1 1.1 0 0 0-.09-.14a.75.75 0 0 0-.14-.17l-.12-.07a.69.69 0 0 0-.19-.1h-.2A.7.7 0 0 0 20 6h-5a1 1 0 0 0 0 2h2.83l-4 4.71l-4.32-2.57a1 1 0 0 0-1.28.22l-5 6a1 1 0 0 0 .13 1.41A1 1 0 0 0 4 18a1 1 0 0 0 .77-.36l4.45-5.34l4.27 2.56a1 1 0 0 0 1.27-.21L19 9.7V12a1 1 0 0 0 2 0V7z"/>
|
||||
</svg>
|
||||
}
|
||||
{type === 'google'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 256 262">
|
||||
<path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/>
|
||||
<path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/>
|
||||
<path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/>
|
||||
<path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335" />
|
||||
</svg>
|
||||
}
|
||||
{type === 'cursor'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path fill="none" stroke={color} strokeWidth="2" d="M6 3l12 11l-5 1l3 5.5l-3 1.5l-3-6l-4 3z"/>
|
||||
</svg>
|
||||
}
|
||||
{type === 'eye'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<g fill="none">
|
||||
<path d="M21.257 10.962c.474.62.474 1.457 0 2.076C19.764 14.987 16.182 19 12 19c-4.182 0-7.764-4.013-9.257-5.962a1.692 1.692 0 0 1 0-2.076C4.236 9.013 7.818 5 12 5c4.182 0 7.764 4.013 9.257 5.962z" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="12" r="3" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</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"/>
|
||||
</svg>
|
||||
}
|
||||
{type === 'help'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8s-8-3.589-8-8s3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2zm4 8a4 4 0 0 0-8 0h2c0-1.103.897-2 2-2s2 .897 2 2s-.897 2-2 2a1 1 0 0 0-1 1v2h2v-1.141A3.991 3.991 0 0 0 16 10zm-3 6h-2v2h2v-2z" fill={color} />
|
||||
</svg>
|
||||
}
|
||||
{type === 'date'
|
||||
&& <svg {...xmlnsProps} width={size} viewBox="0 0 24 24">
|
||||
<path d="M22 2.25h-3.25V.75a.75.75 0 0 0-1.5-.001V2.25h-4.5V.75a.75.75 0 0 0-1.5-.001V2.25h-4.5V.75a.75.75 0 0 0-1.5-.001V2.25H2a2 2 0 0 0-2 1.999v17.75a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2V4.249a2 2 0 0 0-2-1.999zM22.5 22a.5.5 0 0 1-.499.5H2a.5.5 0 0 1-.5-.5V4.25a.5.5 0 0 1 .5-.499h3.25v1.5a.75.75 0 0 0 1.5.001V3.751h4.5v1.5a.75.75 0 0 0 1.5.001V3.751h4.5v1.5a.75.75 0 0 0 1.5.001V3.751H22a.5.5 0 0 1 .499.499z" fill={color} />
|
||||
<path d="M5.25 9h3v2.25h-3z" fill={color} />
|
||||
<path d="M5.25 12.75h3V15h-3z" fill={color} />
|
||||
<path d="M5.25 16.5h3v2.25h-3z" fill={color} />
|
||||
<path d="M10.5 16.5h3v2.25h-3z" fill={color} />
|
||||
<path d="M10.5 12.75h3V15h-3z" fill={color} />
|
||||
<path d="M10.5 9h3v2.25h-3z" fill={color} />
|
||||
<path d="M15.75 16.5h3v2.25h-3z" fill={color} />
|
||||
<path d="M15.75 12.75h3V15h-3z" fill={color} />
|
||||
<path d="M15.75 9h3v2.25h-3z" fill={color} />
|
||||
</svg>
|
||||
}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
className={`selected flex border ${rounded} p-1.5 px-4 cursor-pointer select-none w-[180px] min-w-[${minWidth}px]
|
||||
${showOptions ? 'border-indigo-200' : ''}`}
|
||||
onClick={() => setShowOptions(!showOptions)}>
|
||||
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2`}>
|
||||
<span className={`w-[${minWidth - 30}px] inline-block truncate mr-2 capitalize`}>
|
||||
{selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel}
|
||||
</span>
|
||||
{multiple && selected.length > 2
|
||||
@@ -99,7 +99,7 @@ const SelectField = (props: SelectFieldProps) => {
|
||||
return (
|
||||
<li
|
||||
key={opt.value}
|
||||
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF]
|
||||
className={`select-none cursor-pointer px-3 py-2 hover:bg-[#FCFCFF] capitalize
|
||||
${itemActive ? ' bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''} `}
|
||||
onClick={() => selectItem(opt)}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
|
||||
import Icon from './Icon';
|
||||
|
||||
type SidebarProps = {
|
||||
domains: Domain[],
|
||||
domains: DomainType[],
|
||||
showAddModal: Function
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
|
||||
className={'my-2.5 leading-10'}>
|
||||
<Link href={`/domain/${d.slug}`} passHref={true}>
|
||||
<a className={`block cursor-pointer px-4 text-ellipsis max-w-[215px] overflow-hidden whitespace-nowrap rounded
|
||||
rounded-r-none
|
||||
${(`/domain/${d.slug}` === router.asPath ? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}>
|
||||
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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,6 +12,7 @@ type TopbarProps = {
|
||||
const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
|
||||
const [showMobileMenu, setShowMobileMenu] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const isDomainsPage = router.pathname === '/domains';
|
||||
|
||||
const logoutUser = async () => {
|
||||
try {
|
||||
@@ -28,12 +30,21 @@ const TopBar = ({ showSettings, showAddModal }:TopbarProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="topbar flex w-full max-w-7xl mx-auto justify-between lg:justify-end bg-white lg:bg-transparent">
|
||||
<div className={`topbar flex w-full mx-auto justify-between
|
||||
${isDomainsPage ? 'max-w-5xl lg:justify-between' : 'max-w-7xl lg:justify-end'} bg-white lg:bg-transparent`}>
|
||||
|
||||
<h3 className="p-4 text-base font-bold text-blue-700 lg:hidden">
|
||||
<h3 className={`p-4 text-base font-bold text-blue-700 ${isDomainsPage ? 'lg:pl-0' : 'lg:hidden'}`}>
|
||||
<span className=' relative top-[3px] mr-1'><Icon type="logo" size={24} color="#364AFF" /></span> SerpBear
|
||||
<button className='px-3 py-1 font-bold text-blue-700 lg:hidden ml-3 text-lg' onClick={() => showAddModal()}>+</button>
|
||||
</h3>
|
||||
{!isDomainsPage && (
|
||||
<Link href={'/domains'} passHref={true}>
|
||||
<a className=' right-14 top-2 px-2 py-1 cursor-pointer bg-[#ecf2ff] hover:bg-indigo-100 transition-all
|
||||
absolute lg:top-3 lg:right-auto lg:left-8 lg:px-3 lg:py-2 rounded-full'>
|
||||
<Icon type="caret-left" size={16} title="Go Back" />
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<div className="topbar__right">
|
||||
<button className={' lg:hidden p-3'} onClick={() => setShowMobileMenu(!showMobileMenu)}>
|
||||
<Icon type="hamburger" size={24} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../common/Modal';
|
||||
import { useAddDomain } from '../../services/domains';
|
||||
import { isValidDomain } from '../../utils/validators';
|
||||
|
||||
type AddDomainProps = {
|
||||
closeModal: Function
|
||||
@@ -13,10 +14,10 @@ const AddDomain = ({ closeModal }: AddDomainProps) => {
|
||||
|
||||
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)) {
|
||||
if (isValidDomain(newDomain.trim())) {
|
||||
setNewDomainError(false);
|
||||
// TODO: Domain Action
|
||||
addMutate(newDomain);
|
||||
addMutate(newDomain.trim());
|
||||
} else {
|
||||
setNewDomainError(true);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRefreshKeywords } from '../../services/keywords';
|
||||
import Icon from '../common/Icon';
|
||||
import SelectField from '../common/SelectField';
|
||||
|
||||
type DomainHeaderProps = {
|
||||
domain: Domain,
|
||||
domains: Domain[],
|
||||
domain: DomainType,
|
||||
domains: DomainType[],
|
||||
showAddModal: Function,
|
||||
showSettingsModal: Function,
|
||||
exportCsv:Function
|
||||
exportCsv:Function,
|
||||
scFilter?: string
|
||||
setScFilter?: Function
|
||||
}
|
||||
|
||||
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains }: DomainHeaderProps) => {
|
||||
const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains, scFilter = 'thirtyDays', setScFilter }: DomainHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const [showOptions, setShowOptions] = useState<boolean>(false);
|
||||
|
||||
const [ShowSCDates, setShowSCDates] = useState<boolean>(false);
|
||||
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
|
||||
const isConsole = router.pathname === '/domain/console/[slug]';
|
||||
const isInsight = router.pathname === '/domain/insight/[slug]';
|
||||
|
||||
const daysName = (dayKey:string) => dayKey.replace('three', '3').replace('seven', '7').replace('thirty', '30').replace('Days', ' Days');
|
||||
const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700';
|
||||
const buttonLabelStyle = 'ml-2 text-sm not-italic lg:invisible lg:opacity-0';
|
||||
const tabStyle = 'rounded rounded-b-none cursor-pointer border-[#e9ebff] border-b-0';
|
||||
const scDataFilterStlye = 'px-3 py-2 block w-full';
|
||||
return (
|
||||
<div className='domain_kewywords_head flex w-full justify-between'>
|
||||
<div className='domain_kewywords_head w-full '>
|
||||
<div>
|
||||
<h1 className="hidden lg:block text-xl font-bold my-3" data-testid="domain-header">
|
||||
{domain && domain.domain && <><i className=' capitalize font-bold not-italic'>{domain.domain.charAt(0)}</i>{domain.domain.slice(1)}</>}
|
||||
</h1>
|
||||
<div className='bg-white mt-2 lg:hidden'>
|
||||
<div className='domain_selector bg-white mt-2 lg:hidden'>
|
||||
<SelectField
|
||||
options={domains && domains.length > 0 ? domains.map((d) => { return { label: d.domain, value: d.slug }; }) : []}
|
||||
selected={[domain.slug]}
|
||||
@@ -36,46 +45,99 @@ const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, doma
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex my-3'>
|
||||
<button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
|
||||
<div className='flex w-full justify-between'>
|
||||
<ul className=' flex items-end text-sm relative top-[2px]'>
|
||||
<li className={`${tabStyle} ${router.pathname === '/domain/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
|
||||
<Link href={`/domain/${domain.slug}`} passHref={true}>
|
||||
<a className='px-4 py-2 inline-block'><Icon type="tracking" color='#999' classes='hidden lg:inline-block' />
|
||||
<span className='text-xs lg:text-sm lg:ml-2'>Tracking</span>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={`${tabStyle} ${router.pathname === '/domain/console/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
|
||||
<Link href={`/domain/console/${domain.slug}`} passHref={true}>
|
||||
<a className='px-4 py-2 inline-block'><Icon type="google" size={13} classes='hidden lg:inline-block' />
|
||||
<span className='text-xs lg:text-sm lg:ml-2'>Discover</span>
|
||||
<Icon type='help' size={14} color="#aaa" classes="ml-2 hidden lg:inline-block" title='Discover Keywords you already Rank For' />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={`${tabStyle} ${router.pathname === '/domain/insight/[slug]' ? 'bg-white border border-b-0 font-semibold' : ''}`}>
|
||||
<Link href={`/domain/insight/${domain.slug}`} passHref={true}>
|
||||
<a className='px-4 py-2 inline-block'><Icon type="google" size={13} classes='hidden lg:inline-block' />
|
||||
<span className='text-xs lg:text-sm lg:ml-2'>Insight</span>
|
||||
<Icon type='help' size={14} color="#aaa" classes="ml-2 hidden lg:inline-block" title='Insight for Google Search Console Data' />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div className={'flex mt-3 mb-0 lg:mb-3'}>
|
||||
{!isInsight && <button className={`${buttonStyle} lg:hidden`} onClick={() => setShowOptions(!showOptions)}>
|
||||
<Icon type='dots' size={20} />
|
||||
</button>
|
||||
}
|
||||
{isInsight && <button className={`${buttonStyle} lg:hidden invisible`}>x</button>}
|
||||
<div
|
||||
className={`hidden w-40 ml-[-70px] lg:block absolute mt-10 bg-white border border-gray-100 z-40 rounded
|
||||
lg:z-auto lg:relative lg:mt-0 lg:border-0 lg:w-auto lg:bg-transparent`}
|
||||
style={{ display: showOptions ? 'block' : undefined }}>
|
||||
<button
|
||||
className={`${buttonStyle}`}
|
||||
aria-pressed="false"
|
||||
title='Export as CSV'
|
||||
onClick={() => exportCsv()}>
|
||||
<Icon type='download' size={20} /><i className='ml-2 text-sm not-italic lg:hidden'>Export as csv</i>
|
||||
</button>
|
||||
<button
|
||||
className={`${buttonStyle} lg:ml-3`}
|
||||
aria-pressed="false"
|
||||
title='Refresh All Keyword Positions'
|
||||
onClick={() => refreshMutate({ ids: [], domain: domain.domain })}>
|
||||
<Icon type='reload' size={14} /><i className='ml-2 text-sm not-italic lg:hidden'>Reload All Serps</i>
|
||||
</button>
|
||||
{!isInsight && (
|
||||
<button
|
||||
className={`domheader_action_button relative ${buttonStyle}`}
|
||||
aria-pressed="false"
|
||||
onClick={() => exportCsv()}>
|
||||
<Icon type='download' size={20} /><i className={`${buttonLabelStyle}`}>Export as csv</i>
|
||||
</button>
|
||||
)}
|
||||
{!isConsole && !isInsight && (
|
||||
<button
|
||||
className={`domheader_action_button relative ${buttonStyle} lg:ml-3`}
|
||||
aria-pressed="false"
|
||||
onClick={() => refreshMutate({ ids: [], domain: domain.domain })}>
|
||||
<Icon type='reload' size={14} /><i className={`${buttonLabelStyle}`}>Reload All Serps</i>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid="show_domain_settings"
|
||||
className={`${buttonStyle} lg:ml-3`}
|
||||
className={`domheader_action_button relative ${buttonStyle} lg:ml-3`}
|
||||
aria-pressed="false"
|
||||
title='Domain Settings'
|
||||
onClick={() => showSettingsModal(true)}><Icon type='settings' size={20} />
|
||||
<i className='ml-2 text-sm not-italic lg:hidden'>Domain Settings</i></button>
|
||||
<i className={`${buttonLabelStyle}`}>Domain Settings</i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-testid="add_keyword"
|
||||
className={'ml-2 inline-block px-4 py-2 text-blue-700 font-bold text-sm'}
|
||||
onClick={() => showAddModal(true)}>
|
||||
<span
|
||||
className='text-center leading-4 mr-2 inline-block rounded-full w-7 h-7 pt-1 bg-blue-700 text-white font-bold text-lg'>+</span>
|
||||
<i className=' not-italic hidden lg:inline-block'>Add Keyword</i>
|
||||
</button>
|
||||
{!isConsole && !isInsight && (
|
||||
<button
|
||||
data-testid="add_keyword"
|
||||
className={'ml-2 inline-block px-4 py-2 text-blue-700 font-bold text-sm'}
|
||||
onClick={() => showAddModal(true)}>
|
||||
<span
|
||||
className='text-center leading-4 mr-2 inline-block rounded-full w-7 h-7 pt-1 bg-blue-700 text-white font-bold text-lg'>+</span>
|
||||
<i className=' not-italic hidden lg:inline-block'>Add Keyword</i>
|
||||
</button>
|
||||
)}
|
||||
{isConsole && (
|
||||
<div className='text-xs pl-4 ml-2 border-l border-gray-200 relative'>
|
||||
{/* <span className='hidden lg:inline-block'>Data From Last: </span> */}
|
||||
<span className='block cursor-pointer py-3' onClick={() => setShowSCDates(!ShowSCDates)}>
|
||||
<Icon type='date' size={13} classes="mr-1" /> {daysName(scFilter)}
|
||||
</span>
|
||||
{ShowSCDates && (
|
||||
<div className='absolute w-24 z-50 mt-0 right-0 bg-white border border-gray-200 rounded text-center'>
|
||||
{['threeDays', 'sevenDays', 'thirtyDays'].map((itemKey) => {
|
||||
return <button
|
||||
key={itemKey}
|
||||
className={`${scDataFilterStlye} ${scFilter === itemKey ? ' bg-indigo-100 text-indigo-600' : ''}`}
|
||||
onClick={() => { setShowSCDates(false); if (setScFilter) setScFilter(itemKey); }}
|
||||
>Last {daysName(itemKey)}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
76
components/domains/DomainItem.tsx
Normal file
76
components/domains/DomainItem.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
// import { useRouter } from 'next/router';
|
||||
// import { useState } from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import dayjs from 'dayjs';
|
||||
import Link from 'next/link';
|
||||
import Icon from '../common/Icon';
|
||||
|
||||
type DomainItemProps = {
|
||||
domain: DomainType,
|
||||
selected: boolean,
|
||||
isConsoleIntegrated: boolean,
|
||||
thumb: string,
|
||||
}
|
||||
|
||||
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: DomainItemProps) => {
|
||||
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
|
||||
// const router = useRouter();
|
||||
return (
|
||||
<div className={`domItem bg-white border rounded w-full text-sm mb-10 hover:border-indigo-200 ${selected ? '' : ''}`}>
|
||||
<Link href={`/domain/${slug}`} passHref={true}>
|
||||
<a className='flex flex-col lg:flex-row'>
|
||||
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
|
||||
<div className="domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded border border-gray-200 overflow-hidden">
|
||||
{thumb && <img src={thumb} alt={domain.domain} />}
|
||||
</div>
|
||||
<div className="domain_details flex-1">
|
||||
<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} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 flex flex-col p-4'>
|
||||
<div className=' bg-indigo-50 p-1 px-2 text-xs rounded-full absolute ml-3 mt-[-8px]'>
|
||||
<Icon type="tracking" size={13} color="#364aff" /> Tracker
|
||||
</div>
|
||||
<div className='dom_stats flex flex-1 font-semibold text-2xl p-4 pt-5 rounded border border-[#E9EBFF] text-center'>
|
||||
<div className="flex-1 relative">
|
||||
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Keywords</span>{keywordCount}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Avg position</span>{avgPosition}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isConsoleIntegrated && (
|
||||
<div className='flex-1 flex-col p-4 lg:basis-56'>
|
||||
<div className=' bg-indigo-50 p-1 px-2 text-xs rounded-full absolute ml-3 mt-[-8px]'>
|
||||
<Icon type="google" size={13} /> Search Console (7d)
|
||||
</div>
|
||||
<div className='dom_sc_stats flex flex-1 h-full font-semibold text-2xl p-4 pt-5 rounded border border-[#E9EBFF] text-center'>
|
||||
<div className="flex-1 relative">
|
||||
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Visits</span>
|
||||
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(scVisits).replace('T', 'K')}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Impressions</span>
|
||||
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(scImpressions).replace('T', 'K')}
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<span className='block text-xs lg:text-sm text-gray-500 mb-1'>Avg position</span>
|
||||
{scPosition}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainItem;
|
||||
@@ -5,8 +5,7 @@ import Modal from '../common/Modal';
|
||||
import { useDeleteDomain, useUpdateDomain } from '../../services/domains';
|
||||
|
||||
type DomainSettingsProps = {
|
||||
domain:Domain|false,
|
||||
domains: Domain[],
|
||||
domain:DomainType|false,
|
||||
closeModal: Function
|
||||
}
|
||||
|
||||
@@ -15,7 +14,7 @@ type DomainSettingsError = {
|
||||
msg: string,
|
||||
}
|
||||
|
||||
const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) => {
|
||||
const DomainSettings = ({ domain, closeModal }: DomainSettingsProps) => {
|
||||
const router = useRouter();
|
||||
const [showRemoveDomain, setShowRemoveDomain] = useState<boolean>(false);
|
||||
const [settingsError, setSettingsError] = useState<DomainSettingsError>({ type: '', msg: '' });
|
||||
@@ -24,10 +23,7 @@ const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) =>
|
||||
const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false));
|
||||
const { mutate: deleteMutate } = useDeleteDomain(() => {
|
||||
closeModal(false);
|
||||
const fitleredDomains = domain && domains.filter((d:Domain) => d.domain !== domain.domain);
|
||||
if (fitleredDomains && fitleredDomains[0] && fitleredDomains[0].slug) {
|
||||
router.push(`/domain/${fitleredDomains[0].slug}`);
|
||||
}
|
||||
router.push('/domains');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +41,7 @@ const DomainSettings = ({ domain, domains, 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' };
|
||||
|
||||
132
components/insight/Insight.tsx
Normal file
132
components/insight/Insight.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { sortInsightItems } from '../../utils/insight';
|
||||
import SelectField from '../common/SelectField';
|
||||
import InsightItem from './InsightItem';
|
||||
import InsightStats from './InsightStats';
|
||||
|
||||
type SCInsightProps = {
|
||||
domain: DomainType | null,
|
||||
insight: InsightDataType,
|
||||
isLoading: boolean,
|
||||
isConsoleIntegrated: boolean,
|
||||
}
|
||||
|
||||
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('stats');
|
||||
|
||||
const insightItems = insight[activeTab as keyof InsightDataType];
|
||||
const startDate = insight && insight.stats && insight.stats.length > 0 ? new Date(insight.stats[0].date) : null;
|
||||
const endDate = insight && insight.stats && insight.stats.length > 0 ? new Date(insight.stats[insight.stats.length - 1].date) : null;
|
||||
|
||||
const switchTab = (tab: string) => {
|
||||
// window.insightTab = tab;
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const renderTableHeader = () => {
|
||||
const headerNames: {[key:string]: string[]} = {
|
||||
stats: ['Date', 'Avg Position', 'Visits', 'Impressions', 'CTR'],
|
||||
keywords: ['Keyword', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Countries'],
|
||||
countries: ['Country', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Keywords'],
|
||||
pages: ['Page', 'Avg Position', 'Visits ↑', 'Impressions', 'CTR', 'Countries', 'Keywords'],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`domKeywords_head 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 '>{headerNames[activeTab][0]}</span>
|
||||
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>{headerNames[activeTab][1]}</span>
|
||||
<span className='domKeywords_head_imp flex-1 text-center'>{headerNames[activeTab][2]}</span>
|
||||
<span className='domKeywords_head_visits flex-1 text-center'>{headerNames[activeTab][3]}</span>
|
||||
<span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][4]}</span>
|
||||
{headerNames[activeTab][5] && <span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][5]}</span>}
|
||||
{headerNames[activeTab][6] && <span className='domKeywords_head_ctr flex-1 text-center'>{headerNames[activeTab][6]}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const deviceTabStyle = 'select-none cursor-pointer px-3 py-2 rounded-3xl mr-2';
|
||||
const deviceTabCountStyle = 'px-2 py-0 rounded-3xl bg-[#DEE1FC] text-[0.7rem] font-bold ml-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
|
||||
<div className='domKeywords_filters py-4 px-6 flex flex-col justify-between
|
||||
text-sm text-gray-500 font-semibold border-b-[1px] lg:border-0 lg:flex-row'>
|
||||
<div>
|
||||
<ul className='text-xs hidden lg:flex'>
|
||||
{['stats', 'keywords', 'countries', 'pages'].map((tabItem) => {
|
||||
const tabInsightItem = insight[tabItem as keyof InsightDataType];
|
||||
return <li
|
||||
key={`tab-${tabItem}`}
|
||||
className={`${deviceTabStyle} ${activeTab === tabItem ? ' bg-[#F8F9FF] text-gray-700' : ''}`}
|
||||
onClick={() => switchTab(tabItem)}>
|
||||
<i className='hidden not-italic lg:inline-block ml-1 capitalize'>{tabItem}</i>
|
||||
{tabItem !== 'stats' && (
|
||||
<span className={`${deviceTabCountStyle}`}>
|
||||
{tabInsightItem && tabInsightItem.length ? tabInsightItem.length : 0}
|
||||
</span>
|
||||
)}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
<div className='insight_selector lg:hidden'>
|
||||
<SelectField
|
||||
options={['stats', 'keywords', 'countries', 'pages'].map((d) => { return { label: d, value: d }; })}
|
||||
selected={[activeTab]}
|
||||
defaultLabel="Select Tab"
|
||||
updateField={(updatedTab:[string]) => switchTab(updatedTab[0])}
|
||||
multiple={false}
|
||||
rounded={'rounded'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isConsoleIntegrated && (<div className='py-2 text-xs text-center mt-2 lg:text-sm lg:mt-0'>
|
||||
{startDate && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(startDate))}
|
||||
<span className='px-2 inline-block'>-</span>
|
||||
{endDate && new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(new Date(endDate))}
|
||||
<span className='ml-2'>(Last 30 Days)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isConsoleIntegrated && activeTab === 'stats' && (
|
||||
<InsightStats
|
||||
stats={insight?.stats ? insight.stats : []}
|
||||
totalKeywords={insight?.keywords?.length || 0}
|
||||
totalCountries={insight?.countries?.length || 0}
|
||||
totalPages={insight?.pages?.length || 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='domkeywordsTable domkeywordsTable--sckeywords styled-scrollbar w-full overflow-auto min-h-[60vh]'>
|
||||
<div className=' lg:min-w-[800px]'>
|
||||
{renderTableHeader()}
|
||||
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative'>
|
||||
{['keywords', 'pages', 'countries', 'stats'].includes(activeTab) && insight && insightItems
|
||||
&& (activeTab === 'stats' ? [...insightItems].reverse() : sortInsightItems(insightItems)).map(
|
||||
(item:SCInsightItem, index: number) => {
|
||||
const insightItemCount = insight ? insightItems : [];
|
||||
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
|
||||
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />;
|
||||
},
|
||||
)
|
||||
}
|
||||
{isConsoleIntegrated && isLoading && (
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Insight...</p>
|
||||
)}
|
||||
{!isConsoleIntegrated && (
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCInsight;
|
||||
69
components/insight/InsightItem.tsx
Normal file
69
components/insight/InsightItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import countries from '../../utils/countries';
|
||||
import Icon from '../common/Icon';
|
||||
|
||||
type InsightItemProps = {
|
||||
item: SCInsightItem,
|
||||
lastItem: boolean,
|
||||
type: string
|
||||
}
|
||||
|
||||
const InsightItem = ({ item, lastItem, type }: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
|
||||
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 ${lastItem ? 'border-b-0' : ''}`}>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
|
||||
<span className='mr-1 lg:hidden'>
|
||||
<Icon type="tracking" size={14} color="#999" />
|
||||
</span>
|
||||
{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)}
|
||||
<span className='block text-xs text-gray-500 lg:hidden'>Visits</span>
|
||||
</div>
|
||||
|
||||
<div className='keyword_imp text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
|
||||
<span className='mr-1 lg:hidden'>
|
||||
<Icon type="eye" size={14} color="#999" />
|
||||
</span>
|
||||
{formattedNum(impressions)}
|
||||
</div>
|
||||
|
||||
<div className='keyword_ctr text-center inline-block mt-4 relative mr-3 lg:mr-0 lg:flex-1 lg:m-0 '>
|
||||
<span className='mr-1 lg:hidden'>
|
||||
<Icon type="target" size={14} color="#999" />
|
||||
</span>
|
||||
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(ctr)}%
|
||||
</div>
|
||||
|
||||
{(type === 'pages' || type === 'keywords') && (
|
||||
<div className='keyword_imp text-center hidden lg:inline-block lg:flex-1'>{formattedNum(cntrs)}</div>
|
||||
)}
|
||||
|
||||
{(type === 'countries' || type === 'pages') && (
|
||||
<div className='keyword_imp text-center hidden lg:inline-block lg:flex-1'>{formattedNum(keywords)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightItem;
|
||||
123
components/insight/InsightStats.tsx
Normal file
123
components/insight/InsightStats.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
||||
|
||||
type InsightStatsProps = {
|
||||
stats: SearchAnalyticsStat[],
|
||||
totalKeywords: number,
|
||||
totalCountries: number,
|
||||
totalPages: number,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}, [stats]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const chartSeries: {[key:string]: number[]} = { clicks: [], impressions: [], position: [], ctr: [] };
|
||||
stats.forEach((item) => {
|
||||
chartSeries.clicks.push(item.clicks);
|
||||
chartSeries.impressions.push(item.impressions);
|
||||
chartSeries.position.push(item.position);
|
||||
chartSeries.ctr.push(item.ctr);
|
||||
});
|
||||
return {
|
||||
labels: stats && stats.length > 0 ? stats.map((item) => `${new Date(item.date).getDate()}-${months[new Date(item.date).getMonth()]}`) : [],
|
||||
series: chartSeries };
|
||||
}, [stats]);
|
||||
|
||||
const renderChart = () => {
|
||||
// Doc: https://www.chartjs.org/docs/latest/samples/line/multi-axis.html
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false as const,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const { clicks, impressions } = chartData.series || {};
|
||||
const dataSet = [
|
||||
{ label: 'Visits', data: clicks, borderColor: 'rgb(117, 50, 205)', backgroundColor: 'rgba(117, 50, 205, 0.5)', yAxisID: 'y' },
|
||||
{ label: 'Impressions', data: impressions, borderColor: 'rgb(31, 205, 176)', backgroundColor: 'rgba(31, 205, 176, 0.5)', yAxisID: 'y1' },
|
||||
];
|
||||
return <Line datasetIdKey={'xxx'} options={chartOptions} data={{ labels: chartData.labels, datasets: dataSet }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='p-6 lg:border-t lg:border-gray-200'>
|
||||
<div className=' flex font-bold flex-wrap lg:flex-nowrap'>
|
||||
<div
|
||||
className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-violet-700 mr-5'
|
||||
title={`${formattedNum(totalStat.clicks || 0)} Visits`}>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Visits</span>
|
||||
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(totalStat.clicks || 0).replace('T', 'K')}
|
||||
</div>
|
||||
<div
|
||||
className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-[#1fcdb0] lg:mr-5'
|
||||
title={`${formattedNum(totalStat.impressions || 0)} Impressions`}>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Impressions</span>
|
||||
{new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'short' }).format(totalStat.impressions || 0).replace('T', 'K')}
|
||||
</div>
|
||||
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold mr-5'>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Avg Position</span>
|
||||
{(totalStat.position ? Math.round(totalStat.position / stats.length) : 0)}
|
||||
</div>
|
||||
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold lg:mr-5'>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Avg CTR</span>
|
||||
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(totalStat.ctr || 0)}%
|
||||
</div>
|
||||
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold mr-5'>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Keywords</span>
|
||||
{formattedNum(totalKeywords)}
|
||||
</div>
|
||||
<div className='flex-1 border border-gray-200 px-6 py-5 rounded mb-4 text-2xl text-gray-500 font-semibold'>
|
||||
<span className=' block text-sm font-normal text-gray-500'>Pages</span>
|
||||
{formattedNum(totalPages)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-80'>
|
||||
{renderChart()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightStats;
|
||||
@@ -21,20 +21,23 @@ 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';
|
||||
|
||||
const addKeywords = () => {
|
||||
if (newKeywordsData.keywords) {
|
||||
const keywordsArray = newKeywordsData.keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
|
||||
const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))];
|
||||
const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}`);
|
||||
const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(`${k}-${newKeywordsData.device}-${newKeywordsData.country}`));
|
||||
if (keywordExist.length > 0) {
|
||||
setError(`Keywords ${keywordExist.join(',')} already Exist`);
|
||||
setTimeout(() => { setError(''); }, 3000);
|
||||
} else {
|
||||
addMutate(newKeywordsData);
|
||||
const { device, country, domain: kDomain, tags } = newKeywordsData;
|
||||
const newKeywordsArray = keywordsArray.map((nItem) => ({ keyword: nItem, device, country, domain: kDomain, tags }));
|
||||
addMutate(newKeywordsArray);
|
||||
}
|
||||
} else {
|
||||
setError('Please Insert a Keyword');
|
||||
@@ -62,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}
|
||||
|
||||
60
components/keywords/AddTags.tsx
Normal file
60
components/keywords/AddTags.tsx
Normal 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;
|
||||
@@ -9,17 +9,35 @@ import { generateTheChartData } from '../common/generateChartData';
|
||||
type KeywordProps = {
|
||||
keywordData: KeywordType,
|
||||
selected: boolean,
|
||||
index: number,
|
||||
refreshkeyword: Function,
|
||||
favoriteKeyword: Function,
|
||||
removeKeyword: Function,
|
||||
selectKeyword: Function,
|
||||
manageTags: Function,
|
||||
showKeywordDetails: Function,
|
||||
lastItem?:boolean
|
||||
lastItem?:boolean,
|
||||
showSCData: boolean,
|
||||
scDataType: string,
|
||||
style: Object
|
||||
}
|
||||
|
||||
const Keyword = (props: KeywordProps) => {
|
||||
const { keywordData, refreshkeyword, favoriteKeyword, removeKeyword, selectKeyword, selected, showKeywordDetails, manageTags, lastItem } = props;
|
||||
const {
|
||||
keywordData,
|
||||
refreshkeyword,
|
||||
favoriteKeyword,
|
||||
removeKeyword,
|
||||
selectKeyword,
|
||||
selected,
|
||||
showKeywordDetails,
|
||||
manageTags,
|
||||
lastItem,
|
||||
showSCData = true,
|
||||
style,
|
||||
index,
|
||||
scDataType = 'threeDays',
|
||||
} = props;
|
||||
const {
|
||||
keyword, domain, ID, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false,
|
||||
} = keywordData;
|
||||
@@ -42,27 +60,45 @@ 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 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] };
|
||||
}
|
||||
}
|
||||
|
||||
return bestPos || false;
|
||||
}, [history]);
|
||||
|
||||
const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700';
|
||||
|
||||
const renderPosition = () => {
|
||||
if (position === 0) {
|
||||
return <span title='Not in Top 100'>{'-'}</span>;
|
||||
const renderPosition = (pos:number, type?:string) => {
|
||||
if (!updating && pos === 0) {
|
||||
return <span className='text-gray-400' title='Not in Top 100'>{'>100'}</span>;
|
||||
}
|
||||
if (updating) {
|
||||
if (updating && type !== 'sc') {
|
||||
return <span title='Updating Keyword Position'><Icon type="loading" /></span>;
|
||||
}
|
||||
return position;
|
||||
return pos;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -83,28 +119,64 @@ 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()}
|
||||
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`}>
|
||||
{renderPosition(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>
|
||||
<TimeAgo title={dayjs(lastUpdated).format('DD-MMM-YYYY, hh:mm:ss A')} date={lastUpdated} />
|
||||
</div>
|
||||
<div className='absolute right-7 mt-[-10px] lg:flex-1 lg:basis-5 lg:grow-0 lg:shrink-0 lg:relative lg:mt-0 lg:right-auto'>
|
||||
|
||||
{showSCData && (
|
||||
<div className='keyword_sc_data min-w-[170px] text-xs mt-4 pt-2 border-t border-gray-100 top-[6px]
|
||||
relative flex justify-between text-center lg:flex-1 lg:text-sm lg:m-0 lg:mt-0 lg:border-t-0 lg:pt-0 lg:top-0'>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>SC Position: </span>
|
||||
{renderPosition(keywordData?.scData?.position[scDataType as keyof KeywordSCDataChild] || 0, 'sc')}
|
||||
</span>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>Impressions: </span>{keywordData?.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0}
|
||||
</span>
|
||||
<span className='min-w-[40px]'>
|
||||
<span className='lg:hidden'>Visits: </span>{keywordData?.scData?.visits[scDataType as keyof KeywordSCDataChild] || 0}
|
||||
</span>
|
||||
{/* <span>{keywordData?.scData?.ctr[scDataType] || '0.00%'}</span> */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='absolute right-4 mt-[-10px] top-2 lg:flex-1 lg:basis-5 lg:grow-0 lg:shrink-0 lg:relative lg:right-[-10px]'>
|
||||
<button
|
||||
className={`keyword_dots rounded px-1 text-indigo-300 hover:bg-indigo-50 ${showOptions ? 'bg-indigo-50 text-indigo-600 ' : ''}`}
|
||||
onClick={() => setShowOptions(!showOptions)}>
|
||||
@@ -133,8 +205,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} />)
|
||||
@@ -145,7 +219,8 @@ const Keyword = (props: KeywordProps) => {
|
||||
{lastUpdateError.scraper && <strong className='capitalize'>{lastUpdateError.scraper}: </strong>}{lastUpdateError.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,13 +79,13 @@ const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-30" onClick={closeOnBGClick} data-testid="keywordDetails">
|
||||
<div className="keywordDetails fixed w-full h-screen top-0 left-0 z-[99999]" onClick={closeOnBGClick} data-testid="keywordDetails">
|
||||
<div className="keywordDetails absolute w-full lg:w-5/12 bg-white customShadow top-0 right-0 h-screen" >
|
||||
<div className='keywordDetails__header p-6 border-b border-b-slate-200 text-slate-500'>
|
||||
<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'
|
||||
|
||||
@@ -9,9 +9,12 @@ type KeywordFilterProps = {
|
||||
setDevice: Function,
|
||||
filterParams: KeywordFilters,
|
||||
filterKeywords: Function,
|
||||
keywords: KeywordType[],
|
||||
keywords: KeywordType[] | SearchAnalyticsItem[],
|
||||
updateSort: Function,
|
||||
sortBy: string
|
||||
sortBy: string,
|
||||
integratedConsole?: boolean,
|
||||
isConsole?: boolean,
|
||||
SCcountries?: string[];
|
||||
}
|
||||
|
||||
type KeywordCountState = {
|
||||
@@ -28,7 +31,11 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
keywords,
|
||||
updateSort,
|
||||
sortBy,
|
||||
filterParams } = props;
|
||||
filterParams,
|
||||
isConsole = false,
|
||||
integratedConsole = false,
|
||||
SCcountries = [],
|
||||
} = props;
|
||||
const [keywordCounts, setKeywordCounts] = useState<KeywordCountState>({ desktop: 0, mobile: 0 });
|
||||
const [sortOptions, showSortOptions] = useState(false);
|
||||
const [filterOptions, showFilterOptions] = useState(false);
|
||||
@@ -55,12 +62,16 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
};
|
||||
|
||||
const countryOptions = useMemo(() => {
|
||||
const optionObject = Object.keys(countries).map((countryISO:string) => ({
|
||||
label: countries[countryISO][0],
|
||||
value: countryISO,
|
||||
}));
|
||||
const optionObject:{label:string, value:string}[] = [];
|
||||
|
||||
Object.keys(countries).forEach((countryISO:string) => {
|
||||
if (!isConsole || (isConsole && SCcountries.includes(countryISO))) {
|
||||
optionObject.push({ label: countries[countryISO][0], value: countryISO });
|
||||
}
|
||||
});
|
||||
|
||||
return optionObject;
|
||||
}, []);
|
||||
}, [SCcountries, isConsole]);
|
||||
|
||||
const sortOptionChoices: SelectionOption[] = [
|
||||
{ value: 'pos_asc', label: 'Top Position' },
|
||||
@@ -70,6 +81,17 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
{ value: 'alpha_asc', label: 'Alphabetically(A-Z)' },
|
||||
{ value: 'alpha_desc', label: 'Alphabetically(Z-A)' },
|
||||
];
|
||||
if (integratedConsole) {
|
||||
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);
|
||||
sortOptionChoices.push({ value: 'ctr_asc', label: 'Highest CTR' });
|
||||
sortOptionChoices.push({ value: 'ctr_desc', label: 'Lowest CTR' });
|
||||
}
|
||||
const sortItemStyle = (sortType:string) => {
|
||||
return `cursor-pointer py-2 px-3 hover:bg-[#FCFCFF] ${sortBy === sortType ? 'bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''}`;
|
||||
};
|
||||
@@ -119,15 +141,17 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
flags={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={'tags_filter mb-2 lg:mb-0'}>
|
||||
<SelectField
|
||||
selected={filterParams.tags}
|
||||
options={allTags.map((tag:string) => ({ label: tag, value: tag }))}
|
||||
defaultLabel='All Tags'
|
||||
updateField={(updated:string[]) => filterTags(updated)}
|
||||
emptyMsg="No Tags Found for this Domain"
|
||||
/>
|
||||
</div>
|
||||
{!isConsole && (
|
||||
<div className={'tags_filter mb-2 lg:mb-0'}>
|
||||
<SelectField
|
||||
selected={filterParams.tags}
|
||||
options={allTags.map((tag:string) => ({ label: tag, value: tag }))}
|
||||
defaultLabel='All Tags'
|
||||
updateField={(updated:string[]) => filterTags(updated)}
|
||||
emptyMsg="No Tags Found for this Domain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={'mb-2 lg:mb-0'}>
|
||||
<input
|
||||
data-testid="filter_input"
|
||||
@@ -150,7 +174,8 @@ const KeywordFilters = (props: KeywordFilterProps) => {
|
||||
{sortOptions && (
|
||||
<ul
|
||||
data-testid="sort_options"
|
||||
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg max-h-96 bg-white z-50 w-44'>
|
||||
className='sort_options mt-2 border absolute min-w-[0] right-0 rounded-lg
|
||||
max-h-96 bg-white z-[9999] w-44 overflow-y-auto styled-scrollbar'>
|
||||
{sortOptionChoices.map((sortOption) => {
|
||||
return <li
|
||||
key={sortOption.value}
|
||||
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } 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 Icon from '../common/Icon';
|
||||
@@ -10,33 +11,61 @@ import KeywordFilters from './KeywordFilter';
|
||||
import Modal from '../common/Modal';
|
||||
import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords';
|
||||
import KeywordTagManager from './KeywordTagManager';
|
||||
import AddTags from './AddTags';
|
||||
|
||||
type KeywordsTableProps = {
|
||||
domain: Domain | null,
|
||||
domain: DomainType | null,
|
||||
keywords: KeywordType[],
|
||||
isLoading: boolean,
|
||||
showAddModal: boolean,
|
||||
setShowAddModal: Function
|
||||
setShowAddModal: Function,
|
||||
isConsoleIntegrated: boolean,
|
||||
}
|
||||
|
||||
const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal }: KeywordsTableProps) => {
|
||||
const KeywordsTable = (props: KeywordsTableProps) => {
|
||||
const { domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal, isConsoleIntegrated = false } = props;
|
||||
const showSCData = isConsoleIntegrated;
|
||||
const [device, setDevice] = useState<string>('desktop');
|
||||
const [selectedKeywords, setSelectedKeywords] = useState<number[]>([]);
|
||||
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 [isMobile, setIsMobile] = 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');
|
||||
const [showScDataTypes, setShowScDataTypes] = useState<boolean>(false);
|
||||
const { mutate: deleteMutate } = useDeleteKeywords(() => {});
|
||||
const { mutate: favoriteMutate } = useFavKeywords(() => {});
|
||||
const { mutate: refreshMutate } = useRefreshKeywords(() => {});
|
||||
|
||||
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',
|
||||
avgThirtyDays: 'Last Thirty Days Avg',
|
||||
};
|
||||
|
||||
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 processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => {
|
||||
const procKeywords = keywords.filter((x) => x.device === device);
|
||||
const filteredKeywords = filterKeywords(procKeywords, filterParams);
|
||||
const sortedKeywords = sortKeywords(filteredKeywords, sortBy);
|
||||
const sortedKeywords = sortKeywords(filteredKeywords, sortBy, scDataType);
|
||||
return keywordsByDevice(sortedKeywords, device);
|
||||
}, [keywords, device, sortBy, filterParams]);
|
||||
}, [keywords, device, sortBy, filterParams, scDataType]);
|
||||
|
||||
const allDomainTags: string[] = useMemo(() => {
|
||||
const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []);
|
||||
@@ -51,12 +80,33 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
}
|
||||
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;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border'>
|
||||
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
|
||||
{selectedKeywords.length > 0 && (
|
||||
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
|
||||
<ul className=''>
|
||||
@@ -65,7 +115,7 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
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'>
|
||||
@@ -73,7 +123,14 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
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>
|
||||
@@ -88,13 +145,15 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
keywords={keywords}
|
||||
device={device}
|
||||
setDevice={setDevice}
|
||||
integratedConsole={isConsoleIntegrated}
|
||||
/>
|
||||
)}
|
||||
<div className='styled-scrollbar w-full overflow-auto min-h-[60vh] '>
|
||||
<div className={`domkeywordsTable domkeywordsTable--keywords ${showSCData ? 'domkeywordsTable--hasSC' : ''}
|
||||
styled-scrollbar w-full overflow-auto min-h-[60vh]`}>
|
||||
<div className=' lg:min-w-[800px]'>
|
||||
<div className={`domKeywords_head domKeywords_head--${sortBy} hidden lg:flex p-3 px-6 bg-[#FCFCFF]
|
||||
text-gray-600 justify-between items-center font-semibold border-y`}>
|
||||
<span className='domKeywords_head_keyword flex-1 basis-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
|
||||
@@ -106,30 +165,64 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
)}
|
||||
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 && (
|
||||
<div className='domKeywords_head_sc flex-1 min-w-[170px] mr-7 text-center'>
|
||||
{/* Search Console */}
|
||||
<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-40'
|
||||
onClick={() => setShowScDataTypes(!showScDataTypes)}>
|
||||
<Icon type="google" size={13} /> {scDataObject[scDataType]}
|
||||
<Icon classes="ml-2" type={showScDataTypes ? 'caret-up' : 'caret-down'} size={10} />
|
||||
</div>
|
||||
{showScDataTypes && (
|
||||
<div className='absolute bg-white border border-gray-200 z-50 w-44 rounded mt-2 ml-5 text-gray-500'>
|
||||
{Object.keys(scDataObject).map((itemKey) => {
|
||||
return <span
|
||||
className={`block p-2 cursor-pointer hover:bg-indigo-50 hover:text-indigo-600
|
||||
${scDataType === itemKey ? 'bg-indigo-100 text-indigo-600' : ''}`}
|
||||
key={itemKey}
|
||||
onClick={() => { setScDataType(itemKey); setShowScDataTypes(false); }}>
|
||||
{scDataObject[itemKey]}
|
||||
</span>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='relative top-2 flex justify-between'>
|
||||
<span className='min-w-[40px]'>Pos</span>
|
||||
<span className='min-w-[40px]'>Imp</span>
|
||||
<span className='min-w-[40px]'>Visits</span>
|
||||
{/* <span>CTR</span> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='domKeywords_keywords border-gray-200'>
|
||||
{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)}
|
||||
/>)}
|
||||
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative'>
|
||||
{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 mt-[10%] text-center text-gray-500'>No Keywords Added for this Device Type.</p>
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>No Keywords Added for this Device Type.</p>
|
||||
)}
|
||||
{isLoading && (
|
||||
<p className=' p-9 mt-[10%] text-center text-gray-500'>Loading Keywords...</p>
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Keywords...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,6 +264,12 @@ const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal =
|
||||
closeModal={() => setShowTagManager(null)}
|
||||
/>
|
||||
)}
|
||||
{showAddTags && (
|
||||
<AddTags
|
||||
keywords={keywords.filter((k) => selectedKeywords.includes(k.ID))}
|
||||
closeModal={() => setShowAddTags(false)}
|
||||
/>
|
||||
)}
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
|
||||
77
components/keywords/SCKeyword.tsx
Normal file
77
components/keywords/SCKeyword.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import Icon from '../common/Icon';
|
||||
import countries from '../../utils/countries';
|
||||
|
||||
type SCKeywordProps = {
|
||||
keywordData: SearchAnalyticsItem,
|
||||
selected: boolean,
|
||||
selectKeyword: Function,
|
||||
lastItem?:boolean,
|
||||
isTracked: boolean,
|
||||
style: Object
|
||||
}
|
||||
|
||||
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}
|
||||
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
|
||||
${isTracked || selected ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}
|
||||
${isTracked ? 'bg-gray-400 border-gray-400 cursor-default' : ''}`}
|
||||
onClick={() => !isTracked && selectKeyword(uid)}
|
||||
>
|
||||
<Icon type="check" size={10} title={isTracked ? 'Already in Tracker' : ''} />
|
||||
</button>
|
||||
<a className='py-2 hover:text-blue-600'>
|
||||
<span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} title={countries[country] && countries[country][0]} />{keyword}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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()}
|
||||
<span className='block text-xs text-gray-500 lg:hidden'>Position</span>
|
||||
</div>
|
||||
|
||||
<div className='keyword_imp text-center inline-block lg:flex-1 '>
|
||||
<span className='mr-3 lg:hidden'>
|
||||
<Icon type="eye" size={14} color="#999" />
|
||||
</span>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(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)}
|
||||
</div>
|
||||
|
||||
<div className='keyword_ctr text-center inline-block mt-4 relative lg:flex-1 lg:m-0 '>
|
||||
<span className='mr-3 lg:hidden'>
|
||||
<Icon type="target" size={14} color="#999" />
|
||||
</span>
|
||||
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(ctr)}%
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCKeyword;
|
||||
229
components/keywords/SCKeywordsTable.tsx
Normal file
229
components/keywords/SCKeywordsTable.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState, useMemo, useEffect } 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 Icon from '../common/Icon';
|
||||
import KeywordFilters from './KeywordFilter';
|
||||
import SCKeyword from './SCKeyword';
|
||||
|
||||
type SCKeywordsTableProps = {
|
||||
domain: DomainType | null,
|
||||
keywords: SearchAnalyticsItem[],
|
||||
isLoading: boolean,
|
||||
isConsoleIntegrated: boolean,
|
||||
}
|
||||
|
||||
type SCCountryDataType = {
|
||||
keywords: number,
|
||||
impressions: number,
|
||||
visits: number
|
||||
}
|
||||
|
||||
const SCKeywordsTable = ({ domain, keywords = [], isLoading = true, isConsoleIntegrated = true }: SCKeywordsTableProps) => {
|
||||
const router = useRouter();
|
||||
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_desc');
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
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 finalKeywords: {[key:string] : SCKeywordType[] } = useMemo(() => {
|
||||
const procKeywords = keywords.filter((x) => x.device === device);
|
||||
const filteredKeywords = SCfilterKeywords(procKeywords, filterParams);
|
||||
const sortedKeywords = SCsortKeywords(filteredKeywords, sortBy);
|
||||
return SCkeywordsByDevice(sortedKeywords, device);
|
||||
}, [keywords, device, filterParams, sortBy]);
|
||||
|
||||
const SCCountryData: {[key:string] : SCCountryDataType } = useMemo(() => {
|
||||
const countryData:{[key:string] : SCCountryDataType } = {};
|
||||
|
||||
Object.keys(finalKeywords).forEach((dateKey) => {
|
||||
finalKeywords[dateKey].forEach((keyword) => {
|
||||
const kCountry = keyword.country;
|
||||
if (!countryData[kCountry]) { countryData[kCountry] = { keywords: 0, impressions: 0, visits: 0 }; }
|
||||
countryData[kCountry].keywords += 1;
|
||||
countryData[kCountry].visits += (keyword.clicks || 0);
|
||||
countryData[kCountry].impressions += (keyword.impressions || 0);
|
||||
});
|
||||
});
|
||||
|
||||
return countryData;
|
||||
}, [finalKeywords]);
|
||||
|
||||
const viewSummary: {[key:string] : number } = useMemo(() => {
|
||||
const keyCount = finalKeywords[device].length;
|
||||
const kwSummary = { position: 0, impressions: 0, visits: 0, ctr: 0 };
|
||||
finalKeywords[device].forEach((k) => {
|
||||
kwSummary.position += k.position;
|
||||
kwSummary.impressions += k.impressions;
|
||||
kwSummary.visits += k.clicks;
|
||||
kwSummary.ctr += k.ctr;
|
||||
});
|
||||
return {
|
||||
...kwSummary,
|
||||
position: Math.round(kwSummary.position / keyCount),
|
||||
ctr: kwSummary.ctr / keyCount,
|
||||
};
|
||||
}, [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];
|
||||
if (selectedKeywords.includes(keywordID)) {
|
||||
updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID);
|
||||
}
|
||||
setSelectedKeywords(updatedSelectd);
|
||||
};
|
||||
|
||||
const addSCKeywordsToTracker = () => {
|
||||
const selectedkeywords:KeywordAddPayload[] = [];
|
||||
keywords.forEach((kitem:SCKeywordType) => {
|
||||
if (selectedKeywords.includes(kitem.uid)) {
|
||||
const { keyword, country } = kitem;
|
||||
selectedkeywords.push({ keyword, device, country, domain: domain?.domain || '', tags: '' });
|
||||
}
|
||||
});
|
||||
addKeywords(selectedkeywords);
|
||||
setSelectedKeywords([]);
|
||||
};
|
||||
|
||||
const selectedAllItems = selectedKeywords.length === finalKeywords[device].length;
|
||||
|
||||
const Row = ({ data, index, style }:ListChildComponentProps) => {
|
||||
const keyword = data[index];
|
||||
return (
|
||||
<SCKeyword
|
||||
key={keyword.uid}
|
||||
style={style}
|
||||
selected={selectedKeywords.includes(keyword.uid)}
|
||||
selectKeyword={selectKeyword}
|
||||
keywordData={keyword}
|
||||
isTracked={addedkeywords.includes(`${keyword.keyword}:${keyword.country}:${keyword.device}`)}
|
||||
lastItem={index === (finalKeywords[device].length - 1)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='domKeywords flex flex-col bg-[white] rounded-md text-sm border mb-8'>
|
||||
{selectedKeywords.length > 0 && (
|
||||
<div className='font-semibold text-sm py-4 px-8 text-gray-500 '>
|
||||
<ul className=''>
|
||||
<li className='inline-block mr-4'>
|
||||
<a
|
||||
className='block px-2 py-2 cursor-pointer hover:text-indigo-600'
|
||||
onClick={() => addSCKeywordsToTracker()}
|
||||
>
|
||||
<span className=' bg-indigo-100 text-blue-700 px-1 rounded font-black'>+</span> Add Keywords to Tracker
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{selectedKeywords.length === 0 && (
|
||||
<KeywordFilters
|
||||
allTags={[]}
|
||||
filterParams={filterParams}
|
||||
filterKeywords={(params:KeywordFilters) => setFilterParams(params)}
|
||||
updateSort={(sorted:string) => setSortBy(sorted)}
|
||||
sortBy={sortBy}
|
||||
keywords={keywords}
|
||||
device={device}
|
||||
setDevice={setDevice}
|
||||
isConsole={true}
|
||||
integratedConsole={isConsoleIntegrated}
|
||||
SCcountries={Object.keys(SCCountryData)}
|
||||
/>
|
||||
)}
|
||||
<div className='domkeywordsTable domkeywordsTable--sckeywords styled-scrollbar w-full overflow-auto min-h-[60vh]'>
|
||||
<div className=' lg:min-w-[800px]'>
|
||||
<div className={`domKeywords_head domKeywords_head--${sortBy} hidden lg:flex p-3 px-6 bg-[#FCFCFF]
|
||||
text-gray-600 justify-between items-center font-semibold border-y`}>
|
||||
<span className='domKeywords_head_keyword flex-1 basis-20 w-auto '>
|
||||
{finalKeywords[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
|
||||
${selectedAllItems ? ' bg-blue-700 border-blue-700 text-white' : 'text-transparent'}`}
|
||||
onClick={() => setSelectedKeywords(selectedAllItems ? [] : finalKeywords[device].map((k: SearchAnalyticsItem) => k.uid))}
|
||||
>
|
||||
<Icon type="check" size={10} />
|
||||
</button>
|
||||
)}
|
||||
Keyword
|
||||
</span>
|
||||
<span className='domKeywords_head_position flex-1 basis-40 grow-0 text-center'>Position</span>
|
||||
<span className='domKeywords_head_imp flex-1 text-center'>Impressions</span>
|
||||
<span className='domKeywords_head_visits flex-1 text-center'>Visits</span>
|
||||
<span className='domKeywords_head_ctr flex-1 text-center'>CTR</span>
|
||||
</div>
|
||||
<div className='domKeywords_keywords border-gray-200 min-h-[55vh] relative' data-domain={domain?.domain}>
|
||||
{!isLoading && finalKeywords[device] && finalKeywords[device].length > 0 && (
|
||||
<List
|
||||
innerElementType="div"
|
||||
itemData={finalKeywords[device]}
|
||||
itemCount={finalKeywords[device].length}
|
||||
itemSize={isMobile ? 100 : 57}
|
||||
height={SCListHeight}
|
||||
width={'100%'}
|
||||
className={'styled-scrollbar'}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
)}
|
||||
{!isLoading && finalKeywords[device] && finalKeywords[device].length > 0 && (
|
||||
<div className={`domKeywords_head 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 font-semibold'>
|
||||
{finalKeywords[device].length} {device} Keywords
|
||||
</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)}
|
||||
</span>
|
||||
<span className='domKeywords_head_visits flex-1 text-center'>
|
||||
{new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(viewSummary.visits)}
|
||||
</span>
|
||||
<span className='domKeywords_head_ctr flex-1 text-center'>
|
||||
{new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(viewSummary.ctr)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isConsoleIntegrated && !isLoading && finalKeywords[device].length === 0 && (
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>
|
||||
Could Not fetch Keyword Data for this Domain from Google Search Console.
|
||||
</p>
|
||||
)}
|
||||
{isConsoleIntegrated && isLoading && (
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>Loading Keywords...</p>
|
||||
)}
|
||||
{!isConsoleIntegrated && (
|
||||
<p className=' p-9 pt-[10%] text-center text-gray-500'>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCKeywordsTable;
|
||||
112
components/settings/NotificationSettings.tsx
Normal file
112
components/settings/NotificationSettings.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import SelectField from '../common/SelectField';
|
||||
|
||||
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>
|
||||
<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?.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;
|
||||
149
components/settings/ScraperSettings.tsx
Normal file
149
components/settings/ScraperSettings.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { useClearFailedQueue } from '../../services/settings';
|
||||
import Icon from '../common/Icon';
|
||||
import SelectField, { SelectionOption } from '../common/SelectField';
|
||||
|
||||
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) && (
|
||||
<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?.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?.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;
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
// import { useQuery } from 'react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
|
||||
import Icon from '../common/Icon';
|
||||
import SelectField, { SelectionOption } from '../common/SelectField';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import ScraperSettings from './ScraperSettings';
|
||||
|
||||
type SettingsProps = {
|
||||
closeSettings: Function,
|
||||
@@ -16,6 +17,8 @@ type SettingsError = {
|
||||
|
||||
const defaultSettings = {
|
||||
scraper_type: 'none',
|
||||
scrape_delay: 'none',
|
||||
scrape_retry: false,
|
||||
notification_interval: 'daily',
|
||||
notification_email: '',
|
||||
smtp_server: '',
|
||||
@@ -57,29 +60,26 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
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_username || !settings.smtp_password || !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_password) { type = 'no_smtp_pass'; }
|
||||
if (!settings.smtp_username) { type = 'no_smtp_user'; }
|
||||
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'].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.' };
|
||||
}
|
||||
|
||||
@@ -92,21 +92,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' },
|
||||
];
|
||||
|
||||
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,143 +119,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'].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
|
||||
${settingsError && settingsError.type === 'no_smtp_user' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
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
|
||||
${settingsError && settingsError.type === 'no_smtp_pass' ? ' border-red-400 focus:border-red-400' : ''} `}
|
||||
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()}
|
||||
@@ -279,6 +134,7 @@ const Settings = ({ closeSettings }:SettingsProps) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
122
cron.js
122
cron.js
@@ -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 () => {
|
||||
@@ -35,8 +35,7 @@ const getAppSettings = async () => {
|
||||
}
|
||||
return decryptedSettings;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
// console.log('CRON ERROR: Reading Settings File. ', error);
|
||||
await promises.writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(defaultSettings), { encoding: 'utf-8' });
|
||||
return defaultSettings;
|
||||
}
|
||||
@@ -50,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 0 7 * *';
|
||||
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)
|
||||
@@ -64,37 +66,65 @@ 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 Cron Request..');
|
||||
console.log(err);
|
||||
});
|
||||
}, { scheduled: true });
|
||||
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(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)
|
||||
.then((res) => res.json())
|
||||
.then((data) => console.log(data))
|
||||
.catch((err) => {
|
||||
console.log('ERROR Making Cron Email Notification Request..');
|
||||
console.log(err);
|
||||
});
|
||||
}, { scheduled: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run Failed scraping CRON (Every Hour)
|
||||
const failedCronTime = generateCronTime('hourly');
|
||||
cron.schedule(failedCronTime, () => {
|
||||
Cron(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 Cron Request..');
|
||||
console.log(fetchErr);
|
||||
});
|
||||
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);
|
||||
@@ -102,26 +132,20 @@ const runAppCronJobs = () => {
|
||||
});
|
||||
}, { scheduled: true });
|
||||
|
||||
// RUN Email Notification CRON
|
||||
getAppSettings().then((settings) => {
|
||||
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, () => {
|
||||
// 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)
|
||||
.then((res) => res.json())
|
||||
.then((data) => console.log(data))
|
||||
.catch((err) => {
|
||||
console.log('ERROR Making Cron Request..');
|
||||
console.log(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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -259,13 +259,26 @@
|
||||
color: #fff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.mainhead td {
|
||||
.mainhead td, .subhead td {
|
||||
padding: 15px;
|
||||
}
|
||||
.mainhead a{
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
.subhead {
|
||||
background: #dee3ff;
|
||||
color: #344dd7;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.subhead a {
|
||||
color: #344dd7;
|
||||
text-decoration: none;
|
||||
}
|
||||
.console_table{
|
||||
margin-top: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.keyword_table td {
|
||||
padding: 10px 0;
|
||||
}
|
||||
@@ -277,13 +290,16 @@
|
||||
.keyword td:nth-child(1){
|
||||
font-weight: bold;
|
||||
}
|
||||
.keyword_table th:nth-child(2), .keyword_table th:nth-child(3), .keyword td:nth-child(2), .keyword td:nth-child(3){
|
||||
.keyword_table th:nth-child(2), .keyword_table th:nth-child(3), .keyword td:nth-child(2), .keyword td:nth-child(3), .keyword_table--sc th:nth-child(4), .keyword_table--sc td:nth-child(4){
|
||||
text-align: center;
|
||||
}
|
||||
.keyword td:nth-child(3){
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.keyword_table--sc td:nth-child(3){
|
||||
color:inherit;
|
||||
}
|
||||
.keyword svg {
|
||||
width: 15px;
|
||||
}
|
||||
@@ -304,6 +320,9 @@
|
||||
vertical-align: middle;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.google_icon{
|
||||
max-width: 13px;
|
||||
}
|
||||
/* -------------------------------------
|
||||
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
||||
------------------------------------- */
|
||||
@@ -437,6 +456,9 @@
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
{{SCStatsTable}}
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
|
||||
<!-- START FOOTER -->
|
||||
|
||||
@@ -6,6 +6,6 @@ import './styles/globals.css';
|
||||
|
||||
// Used for __tests__/testing-library.js
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
global.ResizeObserver = require('resize-observer-polyfill');
|
||||
|
||||
@@ -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;
|
||||
|
||||
15189
package-lock.json
generated
15189
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -9,13 +9,14 @@
|
||||
"cron": "node cron.js",
|
||||
"start:all": "concurrently npm:start npm:cron",
|
||||
"lint": "next lint",
|
||||
"lint:css": "stylelint styles/*.css",
|
||||
"test": "jest --watch --verbose",
|
||||
"test:ci": "jest --ci",
|
||||
"test:cv": "jest --coverage --coverageDirectory='coverage'",
|
||||
"release": "standard-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@googleapis/searchconsole": "^1.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"axios": "^1.1.3",
|
||||
"axios-retry": "^3.3.1",
|
||||
@@ -23,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",
|
||||
@@ -40,33 +41,38 @@
|
||||
"react-query": "^3.39.2",
|
||||
"react-timeago": "^7.1.0",
|
||||
"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",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react-timeago": "^4.1.3",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"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",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.7.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.55.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ReactQueryDevtools } from 'react-query/devtools';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const [queryClient] = React.useState(() => new QueryClient());
|
||||
const [queryClient] = React.useState(() => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
return <QueryClientProvider client={queryClient}>
|
||||
<Component {...pageProps} />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
|
||||
29
pages/api/clearfailed.ts
Normal file
29
pages/api/clearfailed.ts
Normal 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!' });
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -35,7 +35,7 @@ const cronRefreshkeywords = async (req: NextApiRequest, res: NextApiResponse<CRO
|
||||
|
||||
return res.status(200).json({ started: true });
|
||||
} catch (error) {
|
||||
console.log('ERROR cronRefreshkeywords: ', error);
|
||||
console.log('[ERROR] CRON Refreshing Keywords: ', error);
|
||||
return res.status(400).json({ started: false, error: 'CRON Error refreshing keywords!' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import Keyword from '../../database/models/keyword';
|
||||
import getdomainStats from '../../utils/domains';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type DomainsGetRes = {
|
||||
domains: Domain[]
|
||||
domains: DomainType[]
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
@@ -47,9 +48,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
export const getDomains = async (req: NextApiRequest, res: NextApiResponse<DomainsGetRes>) => {
|
||||
const withStats = !!req?.query?.withstats;
|
||||
try {
|
||||
const allDomains: Domain[] = await Domain.findAll();
|
||||
return res.status(200).json({ domains: allDomains });
|
||||
const formattedDomains: DomainType[] = allDomains.map((el) => el.get({ plain: true }));
|
||||
const theDomains: DomainType[] = withStats ? await getdomainStats(formattedDomains) : allDomains;
|
||||
return res.status(200).json({ domains: theDomains });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ domains: [], error: 'Error Getting Domains.' });
|
||||
}
|
||||
@@ -61,8 +65,8 @@ export const addDomain = async (req: NextApiRequest, res: NextApiResponse<Domain
|
||||
}
|
||||
const { domain } = req.body || {};
|
||||
const domainData = {
|
||||
domain,
|
||||
slug: domain.replaceAll('.', '-'),
|
||||
domain: domain.trim(),
|
||||
slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'),
|
||||
lastUpdated: new Date().toJSON(),
|
||||
added: new Date().toJSON(),
|
||||
};
|
||||
@@ -88,7 +92,7 @@ export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
|
||||
keywordsRemoved: removedKeywordCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('##### Delete Domain Error: ', error);
|
||||
console.log('[ERROR] Deleting Domain: ', req.query.domain, error);
|
||||
return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' });
|
||||
}
|
||||
};
|
||||
@@ -100,11 +104,15 @@ export const updateDomain = async (req: NextApiRequest, res: NextApiResponse<Dom
|
||||
const { domain } = req.query || {};
|
||||
const { notification_interval, notification_emails } = req.body;
|
||||
|
||||
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
|
||||
if (domainToUpdate) {
|
||||
domainToUpdate.set({ notification_interval, notification_emails });
|
||||
await domainToUpdate.save();
|
||||
try {
|
||||
const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } });
|
||||
if (domainToUpdate) {
|
||||
domainToUpdate.set({ notification_interval, notification_emails });
|
||||
await domainToUpdate.save();
|
||||
}
|
||||
return res.status(200).json({ domain: domainToUpdate });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Updating Domain: ', req.query.domain, error);
|
||||
return res.status(400).json({ domain: null, error: 'Error Updating Domain' });
|
||||
}
|
||||
|
||||
return res.status(200).json({ domain: domainToUpdate });
|
||||
};
|
||||
|
||||
57
pages/api/insight.ts
Normal file
57
pages/api/insight.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import { getCountryInsight, getKeywordsInsight, getPagesInsight } from '../../utils/insight';
|
||||
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type SCInsightRes = {
|
||||
data: InsightDataType | null,
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await db.sync();
|
||||
const authorized = verifyUser(req, res);
|
||||
if (authorized !== 'authorized') {
|
||||
return res.status(401).json({ error: authorized });
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
return getDomainSearchConsoleInsight(req, res);
|
||||
}
|
||||
return res.status(502).json({ error: 'Unrecognized Route.' });
|
||||
}
|
||||
|
||||
const getDomainSearchConsoleInsight = async (req: NextApiRequest, res: NextApiResponse<SCInsightRes>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
|
||||
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
|
||||
}
|
||||
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
|
||||
const getInsightFromSCData = (localSCData: SCDomainDataType): InsightDataType => {
|
||||
const { stats = [] } = localSCData;
|
||||
const countries = getCountryInsight(localSCData);
|
||||
const keywords = getKeywordsInsight(localSCData);
|
||||
const pages = getPagesInsight(localSCData);
|
||||
return { pages, keywords, countries, stats };
|
||||
};
|
||||
|
||||
// First try and read the Local SC Domain Data file.
|
||||
const localSCData = await readLocalSCData(domainname);
|
||||
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 });
|
||||
}
|
||||
|
||||
// If the Local SC Domain Data file does not exist, fetch from Googel Search Console.
|
||||
try {
|
||||
const scData = await fetchDomainSCData(domainname);
|
||||
const response = getInsightFromSCData(scData);
|
||||
return res.status(200).json({ data: response });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting Domain Insight: ', domainname, error);
|
||||
return res.status(400).json({ data: null, error: 'Error Fetching Stats from Google Search Console.' });
|
||||
}
|
||||
};
|
||||
@@ -22,7 +22,6 @@ const getKeyword = async (req: NextApiRequest, res: NextApiResponse<KeywordGetRe
|
||||
if (!req.query.id && typeof req.query.id !== 'string') {
|
||||
return res.status(400).json({ error: 'Keyword ID is Required!' });
|
||||
}
|
||||
console.log('KEYWORD: ', req.query.id);
|
||||
|
||||
try {
|
||||
const query = { ID: parseInt((req.query.id as string), 10) };
|
||||
@@ -31,7 +30,7 @@ const getKeyword = async (req: NextApiRequest, res: NextApiResponse<KeywordGetRe
|
||||
const keywords = pareseKeyword && pareseKeyword[0] ? pareseKeyword[0] : null;
|
||||
return res.status(200).json({ keyword: keywords });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log('[ERROR] Getting Keyword: ', error);
|
||||
return res.status(400).json({ error: 'Error Loading Keyword' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +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[],
|
||||
@@ -44,12 +45,14 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') {
|
||||
return res.status(400).json({ error: 'Domain is Required!' });
|
||||
}
|
||||
const domain = (req.query.domain as string).replaceAll('-', '.');
|
||||
const domain = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
|
||||
const integratedSC = process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL;
|
||||
const domainSCData = integratedSC ? await readLocalSCData(domain) : false;
|
||||
|
||||
try {
|
||||
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } });
|
||||
const keywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
const slimKeywords = keywords.map((keyword) => {
|
||||
const processedKeywords = keywords.map((keyword) => {
|
||||
const historyArray = Object.keys(keyword.history).map((dateKey:string) => ({
|
||||
date: new Date(dateKey).getTime(),
|
||||
dateRaw: dateKey,
|
||||
@@ -58,24 +61,26 @@ const getKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
const historySorted = historyArray.sort((a, b) => a.date - b.date);
|
||||
const lastWeekHistory :KeywordHistory = {};
|
||||
historySorted.slice(-7).forEach((x:any) => { lastWeekHistory[x.dateRaw] = x.position; });
|
||||
return { ...keyword, lastResult: [], history: lastWeekHistory };
|
||||
const keywordWithSlimHistory = { ...keyword, lastResult: [], history: lastWeekHistory };
|
||||
const finalKeyword = domainSCData ? integrateKeywordSCData(keywordWithSlimHistory, domainSCData) : keywordWithSlimHistory;
|
||||
return finalKeyword;
|
||||
});
|
||||
console.log('getKeywords: ', keywords.length);
|
||||
return res.status(200).json({ keywords: slimKeywords });
|
||||
return res.status(200).json({ keywords: processedKeywords });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log('[ERROR] Getting Domain Keywords for ', domain, error);
|
||||
return res.status(400).json({ error: 'Error Loading Keywords for this Domain.' });
|
||||
}
|
||||
};
|
||||
|
||||
const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGetResponse>) => {
|
||||
const { keywords, device, country, domain, tags } = req.body;
|
||||
if (keywords && device && country) {
|
||||
const keywordsArray = keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
|
||||
const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : [];
|
||||
const { keywords } = req.body;
|
||||
if (keywords && Array.isArray(keywords) && keywords.length > 0) {
|
||||
// const keywordsArray = keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim());
|
||||
const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936
|
||||
|
||||
keywordsArray.forEach((keyword: string) => {
|
||||
keywords.forEach((kwrd: KeywordAddPayload) => {
|
||||
const { keyword, device, country, domain, tags } = kwrd;
|
||||
const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : [];
|
||||
const newKeyword = {
|
||||
keyword,
|
||||
device,
|
||||
@@ -101,6 +106,7 @@ const addKeywords = async (req: NextApiRequest, res: NextApiResponse<KeywordsGet
|
||||
refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process
|
||||
return res.status(201).json({ keywords: keywordsParsed });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Adding New Keywords ', error);
|
||||
return res.status(400).json({ error: 'Could Not Add New Keyword!' });
|
||||
}
|
||||
} else {
|
||||
@@ -120,6 +126,7 @@ const deleteKeywords = async (req: NextApiRequest, res: NextApiResponse<Keywords
|
||||
const removedKeywordCount: number = await Keyword.destroy(removeQuery);
|
||||
return res.status(200).json({ keywordsRemoved: removedKeywordCount });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Removing Keyword. ', error);
|
||||
return res.status(400).json({ error: 'Could Not Remove Keyword!' });
|
||||
}
|
||||
};
|
||||
@@ -146,17 +153,20 @@ 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 });
|
||||
}
|
||||
return res.status(400).json({ error: 'Invalid Payload!' });
|
||||
} catch (error) {
|
||||
console.log('ERROR updateKeywords: ', error);
|
||||
console.log('[ERROR] Updating Keyword. ', error);
|
||||
return res.status(200).json({ error: 'Error Updating keywords!' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -21,44 +21,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
|
||||
const notify = async (req: NextApiRequest, res: NextApiResponse<NotifyResponse>) => {
|
||||
const reqDomain = req?.query?.domain as string || '';
|
||||
try {
|
||||
const settings = await getAppSettings();
|
||||
const {
|
||||
smtp_server = '',
|
||||
smtp_port = '',
|
||||
smtp_username = '',
|
||||
smtp_password = '',
|
||||
notification_email = '',
|
||||
notification_email_from = '',
|
||||
} = settings;
|
||||
const { smtp_server = '', smtp_port = '', notification_email = '' } = settings;
|
||||
|
||||
if (!smtp_server || !smtp_port || !smtp_username || !smtp_password || !notification_email) {
|
||||
if (!smtp_server || !smtp_port || !notification_email) {
|
||||
return res.status(401).json({ success: false, error: 'SMTP has not been setup properly!' });
|
||||
}
|
||||
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`;
|
||||
const transporter = nodeMailer.createTransport({
|
||||
host: smtp_server,
|
||||
port: parseInt(smtp_port, 10),
|
||||
auth: { user: smtp_username, pass: smtp_password },
|
||||
});
|
||||
|
||||
const allDomains: Domain[] = await Domain.findAll();
|
||||
|
||||
if (allDomains && allDomains.length > 0) {
|
||||
const domains = allDomains.map((el) => el.get({ plain: true }));
|
||||
for (const domain of domains) {
|
||||
if (domain.notification !== false) {
|
||||
const query = { where: { domain: domain.domain } };
|
||||
const domainKeywords:Keyword[] = await Keyword.findAll(query);
|
||||
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
|
||||
const keywords: KeywordType[] = parseKeywords(keywordsArray);
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: domain.notification_emails || notification_email,
|
||||
subject: `[${domain.domain}] Keyword Positions Update`,
|
||||
html: await generateEmail(domain.domain, keywords),
|
||||
});
|
||||
// console.log(JSON.stringify(result, null, 4));
|
||||
if (reqDomain) {
|
||||
const theDomain = await Domain.findOne({ where: { domain: reqDomain } });
|
||||
if (theDomain) {
|
||||
await sendNotificationEmail(theDomain, settings);
|
||||
}
|
||||
} else {
|
||||
const allDomains: Domain[] = await Domain.findAll();
|
||||
if (allDomains && allDomains.length > 0) {
|
||||
const domains = allDomains.map((el) => el.get({ plain: true }));
|
||||
for (const domain of domains) {
|
||||
if (domain.notification !== false) {
|
||||
await sendNotificationEmail(domain, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,3 +53,35 @@ const notify = async (req: NextApiRequest, res: NextApiResponse<NotifyResponse>)
|
||||
return res.status(401).json({ success: false, error: 'Error Sending Notification Email.' });
|
||||
}
|
||||
};
|
||||
|
||||
const sendNotificationEmail = async (domain: Domain, settings: SettingsType) => {
|
||||
const {
|
||||
smtp_server = '',
|
||||
smtp_port = '',
|
||||
smtp_username = '',
|
||||
smtp_password = '',
|
||||
notification_email = '',
|
||||
notification_email_from = '',
|
||||
} = settings;
|
||||
|
||||
const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`;
|
||||
const mailerSettings:any = { host: smtp_server, port: parseInt(smtp_port, 10) };
|
||||
if (smtp_username || smtp_password) {
|
||||
mailerSettings.auth = {};
|
||||
if (smtp_username) mailerSettings.auth.user = smtp_username;
|
||||
if (smtp_password) mailerSettings.auth.pass = smtp_password;
|
||||
}
|
||||
const transporter = nodeMailer.createTransport(mailerSettings);
|
||||
const domainName = domain.domain;
|
||||
const query = { where: { domain: domainName } };
|
||||
const domainKeywords:Keyword[] = await Keyword.findAll(query);
|
||||
const keywordsArray = domainKeywords.map((el) => el.get({ plain: true }));
|
||||
const keywords: KeywordType[] = parseKeywords(keywordsArray);
|
||||
const emailHTML = await generateEmail(domainName, keywords);
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: domain.notification_emails || notification_email,
|
||||
subject: `[${domainName}] Keyword Positions Update`,
|
||||
html: emailHTML,
|
||||
}).catch((err:any) => console.log('[ERROR] Sending Notification Email for', domainName, err?.response || err));
|
||||
};
|
||||
|
||||
@@ -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: JSON.stringify(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;
|
||||
};
|
||||
|
||||
65
pages/api/searchconsole.ts
Normal file
65
pages/api/searchconsole.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import db from '../../database/database';
|
||||
import Domain from '../../database/models/domain';
|
||||
import { fetchDomainSCData, readLocalSCData } from '../../utils/searchConsole';
|
||||
import verifyUser from '../../utils/verifyUser';
|
||||
|
||||
type searchConsoleRes = {
|
||||
data: SCDomainDataType|null
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
type searchConsoleCRONRes = {
|
||||
status: string,
|
||||
error?: string|null,
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
await db.sync();
|
||||
const authorized = verifyUser(req, res);
|
||||
if (authorized !== 'authorized') {
|
||||
return res.status(401).json({ error: authorized });
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
return getDomainSearchConsoleData(req, res);
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
return cronRefreshSearchConsoleData(req, res);
|
||||
}
|
||||
return res.status(502).json({ error: 'Unrecognized Route.' });
|
||||
}
|
||||
|
||||
const getDomainSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleRes>) => {
|
||||
if (!req.query.domain && typeof req.query.domain !== 'string') return res.status(400).json({ data: null, error: 'Domain is Missing.' });
|
||||
if (!!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL) === false) {
|
||||
return res.status(200).json({ data: null, error: 'Google Search Console Not Integrated' });
|
||||
}
|
||||
const domainname = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-');
|
||||
const localSCData = await readLocalSCData(domainname);
|
||||
console.log(localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length);
|
||||
|
||||
if (localSCData && localSCData.thirtyDays && localSCData.thirtyDays.length) {
|
||||
return res.status(200).json({ data: localSCData });
|
||||
}
|
||||
try {
|
||||
const scData = await fetchDomainSCData(domainname);
|
||||
return res.status(200).json({ data: scData });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting Search Console Data for: ', domainname, error);
|
||||
return res.status(400).json({ data: null, error: 'Error Fetching Data from Google Search Console.' });
|
||||
}
|
||||
};
|
||||
|
||||
const cronRefreshSearchConsoleData = async (req: NextApiRequest, res: NextApiResponse<searchConsoleCRONRes>) => {
|
||||
try {
|
||||
const allDomainsRaw = await Domain.findAll();
|
||||
const Domains: Domain[] = allDomainsRaw.map((el) => el.get({ plain: true }));
|
||||
for (const domain of Domains) {
|
||||
await fetchDomainSCData(domain.domain);
|
||||
}
|
||||
return res.status(200).json({ status: 'completed' });
|
||||
} catch (error) {
|
||||
console.log('[ERROR] CRON Updating Search Console Data. ', error);
|
||||
return res.status(400).json({ status: 'failed', error: 'Error Fetching Data from Google Search Console.' });
|
||||
}
|
||||
};
|
||||
@@ -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!' });
|
||||
};
|
||||
@@ -45,7 +49,7 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' });
|
||||
return res.status(200).json({ settings });
|
||||
} catch (error) {
|
||||
console.log('ERROR updateSettings: ', error);
|
||||
console.log('[ERROR] Updating App Settings. ', error);
|
||||
return res.status(200).json({ error: 'Error Updating Settings!' });
|
||||
}
|
||||
};
|
||||
@@ -53,6 +57,8 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
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;
|
||||
|
||||
@@ -60,14 +66,21 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
const cryptr = new Cryptr(process.env.SECRET as string);
|
||||
const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : '';
|
||||
const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : '';
|
||||
decryptedSettings = { ...settings, scaping_api, smtp_password };
|
||||
decryptedSettings = {
|
||||
...settings,
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Error Decrypting Settings API Keys!');
|
||||
}
|
||||
|
||||
return decryptedSettings;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log('[ERROR] Getting App Settings. ', error);
|
||||
const settings = {
|
||||
scraper_type: 'none',
|
||||
notification_interval: 'never',
|
||||
@@ -77,8 +90,14 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,19 +29,19 @@ const SingleDomain: NextPage = () => {
|
||||
const { data: domainsData } = useFetchDomains(router);
|
||||
const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval);
|
||||
|
||||
const theDomains: Domain[] = (domainsData && domainsData.domains) || [];
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords;
|
||||
|
||||
const activDomain: Domain|null = useMemo(() => {
|
||||
let active:Domain|null = null;
|
||||
const activDomain: DomainType|null = useMemo(() => {
|
||||
let active:DomainType|null = null;
|
||||
if (domainsData?.domains && router.query?.slug) {
|
||||
active = domainsData.domains.find((x:Domain) => x.slug === router.query.slug);
|
||||
active = domainsData.domains.find((x:DomainType) => x.slug === router.query.slug);
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
|
||||
setNoScrapprtError(true);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const SingleDomain: NextPage = () => {
|
||||
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="flex w-full max-w-7xl mx-auto">
|
||||
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-20 w-full">
|
||||
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
|
||||
{activDomain && activDomain.domain
|
||||
&& <DomainHeader
|
||||
domain={activDomain}
|
||||
@@ -72,13 +72,15 @@ const SingleDomain: NextPage = () => {
|
||||
showAddModal={setShowAddKeywords}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV(theKeywords, activDomain.domain)}
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
<KeywordsTable
|
||||
isLoading={keywordsLoading}
|
||||
domain={activDomain}
|
||||
keywords={theKeywords}
|
||||
showAddModal={showAddKeywords}
|
||||
setShowAddModal={setShowAddKeywords}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +92,6 @@ const SingleDomain: NextPage = () => {
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<DomainSettings
|
||||
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
|
||||
domains={theDomains}
|
||||
closeModal={setShowDomainSettings}
|
||||
/>
|
||||
</CSSTransition>
|
||||
|
||||
90
pages/domain/console/[slug]/index.tsx
Normal file
90
pages/domain/console/[slug]/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
// import { useQuery } from 'react-query';
|
||||
// import toast from 'react-hot-toast';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import Sidebar from '../../../../components/common/Sidebar';
|
||||
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 Settings from '../../../../components/settings/Settings';
|
||||
import { useFetchDomains } from '../../../../services/domains';
|
||||
import { useFetchSCKeywords } from '../../../../services/searchConsole';
|
||||
import SCKeywordsTable from '../../../../components/keywords/SCKeywordsTable';
|
||||
import { useFetchSettings } from '../../../../services/settings';
|
||||
|
||||
const DiscoverPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [showDomainSettings, setShowDomainSettings] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||
const [scDateFilter, setSCDateFilter] = useState('thirtyDays');
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: domainsData } = useFetchDomains(router);
|
||||
const scConnected = !!(appSettings && appSettings?.settings?.search_console_integrated);
|
||||
const { data: keywordsData, isLoading: keywordsLoading, isFetching } = useFetchSCKeywords(router, !!(domainsData?.domains?.length) && scConnected);
|
||||
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theKeywords: SearchAnalyticsItem[] = keywordsData?.data && keywordsData.data[scDateFilter] ? keywordsData.data[scDateFilter] : [];
|
||||
|
||||
const 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);
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
&& <Head>
|
||||
<title>{`${activDomain.domain} - SerpBear` } </title>
|
||||
</Head>
|
||||
}
|
||||
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="flex w-full max-w-7xl mx-auto">
|
||||
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
|
||||
{activDomain && activDomain.domain
|
||||
&& <DomainHeader
|
||||
domain={activDomain}
|
||||
domains={theDomains}
|
||||
showAddModal={() => console.log('XXXXX')}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV(theKeywords, activDomain.domain, scDateFilter)}
|
||||
scFilter={scDateFilter}
|
||||
setScFilter={(item:string) => setSCDateFilter(item)}
|
||||
/>
|
||||
}
|
||||
<SCKeywordsTable
|
||||
isLoading={keywordsLoading || isFetching}
|
||||
domain={activDomain}
|
||||
keywords={theKeywords}
|
||||
isConsoleIntegrated={scConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
</CSSTransition>
|
||||
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<DomainSettings
|
||||
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
|
||||
closeModal={setShowDomainSettings}
|
||||
/>
|
||||
</CSSTransition>
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverPage;
|
||||
90
pages/domain/insight/[slug]/index.tsx
Normal file
90
pages/domain/insight/[slug]/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
// import { useQuery } from 'react-query';
|
||||
// import toast from 'react-hot-toast';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import Sidebar from '../../../../components/common/Sidebar';
|
||||
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 Settings from '../../../../components/settings/Settings';
|
||||
import { useFetchDomains } from '../../../../services/domains';
|
||||
import { useFetchSCInsight } from '../../../../services/searchConsole';
|
||||
import SCInsight from '../../../../components/insight/Insight';
|
||||
import { useFetchSettings } from '../../../../services/settings';
|
||||
|
||||
const InsightPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [showDomainSettings, setShowDomainSettings] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||
const [scDateFilter, setSCDateFilter] = useState('thirtyDays');
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: domainsData } = useFetchDomains(router);
|
||||
const scConnected = !!(appSettings && appSettings?.settings?.search_console_integrated);
|
||||
const { data: insightData } = useFetchSCInsight(router, !!(domainsData?.domains?.length) && scConnected);
|
||||
|
||||
const theDomains: DomainType[] = (domainsData && domainsData.domains) || [];
|
||||
const theInsight: InsightDataType = insightData && insightData.data ? insightData.data : {};
|
||||
|
||||
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);
|
||||
}
|
||||
return active;
|
||||
}, [router.query.slug, domainsData]);
|
||||
|
||||
return (
|
||||
<div className="Domain ">
|
||||
{activDomain && activDomain.domain
|
||||
&& <Head>
|
||||
<title>{`${activDomain.domain} - SerpBear` } </title>
|
||||
</Head>
|
||||
}
|
||||
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="flex w-full max-w-7xl mx-auto">
|
||||
<Sidebar domains={theDomains} showAddModal={() => setShowAddDomain(true)} />
|
||||
<div className="domain_kewywords px-5 pt-10 lg:px-0 lg:pt-8 w-full">
|
||||
{activDomain && activDomain.domain
|
||||
&& <DomainHeader
|
||||
domain={activDomain}
|
||||
domains={theDomains}
|
||||
showAddModal={() => console.log('XXXXX')}
|
||||
showSettingsModal={setShowDomainSettings}
|
||||
exportCsv={() => exportCSV([], activDomain.domain, scDateFilter)}
|
||||
scFilter={scDateFilter}
|
||||
setScFilter={(item:string) => setSCDateFilter(item)}
|
||||
/>
|
||||
}
|
||||
<SCInsight
|
||||
isLoading={false}
|
||||
domain={activDomain}
|
||||
insight={theInsight}
|
||||
isConsoleIntegrated={scConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
</CSSTransition>
|
||||
|
||||
<CSSTransition in={showDomainSettings} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<DomainSettings
|
||||
domain={showDomainSettings && theDomains && activDomain && activDomain.domain ? activDomain : false}
|
||||
closeModal={setShowDomainSettings}
|
||||
/>
|
||||
</CSSTransition>
|
||||
<CSSTransition in={showSettings} timeout={300} classNames="settings_anim" unmountOnExit mountOnEnter>
|
||||
<Settings closeSettings={() => setShowSettings(false)} />
|
||||
</CSSTransition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightPage;
|
||||
122
pages/domains/index.tsx
Normal file
122
pages/domains/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, 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 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 DomainItem from '../../components/domains/DomainItem';
|
||||
import Icon from '../../components/common/Icon';
|
||||
|
||||
type thumbImages = { [domain:string] : string }
|
||||
|
||||
const SingleDomain: 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);
|
||||
if (domainsData?.domains && domainsData.domains.length > 0) {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
if (domain.domain) {
|
||||
if (!domThumbs[domain.domain]) {
|
||||
const domainImageBlob = await fetch(`https://image.thum.io/get/auth/66909-serpbear/maxAge/96/width/200/https://${domain.domain}`).then((res) => res.blob());
|
||||
if (domainImageBlob) {
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(domainImageBlob);
|
||||
});
|
||||
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
|
||||
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain.domain]: imageBase }));
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: imageBase }));
|
||||
}
|
||||
} else {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domThumbs[domain.domain] }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) {
|
||||
setNoScrapprtError(true);
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
return (
|
||||
<div 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.
|
||||
</div>
|
||||
)}
|
||||
<Head>
|
||||
<title>Domains - SerpBear</title>
|
||||
</Head>
|
||||
<TopBar showSettings={() => setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} />
|
||||
|
||||
<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>
|
||||
<button
|
||||
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
|
||||
onClick={() => setShowAddDomain(true)}>
|
||||
<span
|
||||
className='text-center leading-4 mr-2 inline-block rounded-full w-7 h-7 pt-1 bg-blue-700 text-white font-bold text-lg'>+</span>
|
||||
<i className=' not-italic hidden lg:inline-block'>Add Domain</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full flex-col mb-8'>
|
||||
{domainsData?.domains && domainsData.domains.map((domain:DomainType) => {
|
||||
return <DomainItem
|
||||
key={domain.ID}
|
||||
domain={domain}
|
||||
selected={false}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
thumb={domainThumbs[domain.domain]}
|
||||
// isConsoleIntegrated={false}
|
||||
/>;
|
||||
})}
|
||||
{isLoading && (
|
||||
<div className='noDomains mt-4 p-5 py-12 rounded border text-center bg-white text-sm'>
|
||||
<Icon type="loading" /> Loading Domains...
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && domainsData && domainsData.domains && domainsData.domains.length === 0 && (
|
||||
<div className='noDomains mt-4 p-5 py-12 rounded border text-center bg-white text-sm'>
|
||||
No Domains Found. Add a Domain to get started!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CSSTransition in={showAddDomain} timeout={300} classNames="modal_anim" unmountOnExit mountOnEnter>
|
||||
<AddDomain closeModal={() => setShowAddDomain(false)} />
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleDomain;
|
||||
@@ -1,40 +1,14 @@
|
||||
import type { NextPage } from 'next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
// import { useEffect, useState } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Icon from '../components/common/Icon';
|
||||
import AddDomain from '../components/domains/AddDomain';
|
||||
|
||||
// import verifyUser from '../utils/verifyUser';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${window.location.origin}/api/domains`)
|
||||
.then((result) => {
|
||||
if (result.status === 401) {
|
||||
router.push('/login');
|
||||
}
|
||||
return result.json();
|
||||
})
|
||||
.then((domainsRes:any) => {
|
||||
if (domainsRes?.domains && domainsRes.domains.length > 0) {
|
||||
const firstDomainItem = domainsRes.domains[0].slug;
|
||||
setDomains(domainsRes.domains);
|
||||
router.push(`/domain/${firstDomainItem}`);
|
||||
}
|
||||
setLoading(false);
|
||||
return false;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
setLoading(false);
|
||||
});
|
||||
router.push('/domains');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
@@ -49,37 +23,8 @@ const Home: NextPage = () => {
|
||||
<Icon type='loading' size={36} color="#999" />
|
||||
</main>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
{!loading && domains.length === 0 && <AddDomain closeModal={() => console.log('Cannot Close Modal!')} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// export const getServerSideProps = async (context:NextPageContext) => {
|
||||
// const { req, res } = context;
|
||||
// const authorized = verifyUser(req as NextApiRequest, res as NextApiResponse);
|
||||
// // console.log('####### authorized: ', authorized);
|
||||
|
||||
// if (authorized !== 'authorized') {
|
||||
// return { redirect: { destination: '/login', permanent: false } };
|
||||
// }
|
||||
|
||||
// let domains: Domain[] = [];
|
||||
// try {
|
||||
// const fetchOpts = { method: 'GET', headers: { Authorization: `Bearer ${process.env.APIKEY}` } };
|
||||
// const domainsRes = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`, fetchOpts).then((result) => result.json());
|
||||
// // console.log(domainsRes);
|
||||
|
||||
// domains = domainsRes.domains;
|
||||
// if (domains.length > 0) {
|
||||
// const firstDomainItem = domains[0].slug;
|
||||
// return { redirect: { destination: `/domain/${firstDomainItem}`, permanent: false } };
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.log(error);
|
||||
// }
|
||||
|
||||
// // console.log('domains: ', domains);
|
||||
// return { props: { authorized, domains } };
|
||||
// };
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
17
scrapers/index.ts
Normal file
17
scrapers/index.ts
Normal 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,
|
||||
];
|
||||
35
scrapers/services/proxy.ts
Normal file
35
scrapers/services/proxy.ts
Normal 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;
|
||||
20
scrapers/services/scrapingant.ts
Normal file
20
scrapers/services/scrapingant.ts
Normal 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;
|
||||
15
scrapers/services/scrapingrobot.ts
Normal file
15
scrapers/services/scrapingrobot.ts
Normal 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;
|
||||
38
scrapers/services/searchapi.ts
Normal file
38
scrapers/services/searchapi.ts
Normal 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;
|
||||
38
scrapers/services/serpapi.ts
Normal file
38
scrapers/services/serpapi.ts
Normal 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;
|
||||
42
scrapers/services/serply.ts
Normal file
42
scrapers/services/serply.ts
Normal 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;
|
||||
34
scrapers/services/spaceserp.ts
Normal file
34
scrapers/services/spaceserp.ts
Normal 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;
|
||||
@@ -4,11 +4,11 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
type UpdatePayload = {
|
||||
domainSettings: DomainSettings,
|
||||
domain: Domain
|
||||
domain: DomainType
|
||||
}
|
||||
|
||||
export async function fetchDomains(router: NextRouter) {
|
||||
const res = await fetch(`${window.location.origin}/api/domains`, { method: 'GET' });
|
||||
export async function fetchDomains(router: NextRouter, withStats:boolean) {
|
||||
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) {
|
||||
console.log('Unauthorized!!');
|
||||
@@ -19,8 +19,8 @@ export async function fetchDomains(router: NextRouter) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useFetchDomains(router: NextRouter) {
|
||||
return useQuery('domains', () => fetchDomains(router));
|
||||
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
|
||||
return useQuery('domains', () => fetchDomains(router, withStats));
|
||||
}
|
||||
|
||||
export function useAddDomain(onSuccess:Function) {
|
||||
@@ -37,7 +37,7 @@ export function useAddDomain(onSuccess:Function) {
|
||||
}, {
|
||||
onSuccess: async (data) => {
|
||||
console.log('Domain Added!!!', data);
|
||||
const newDomain:Domain = data.domain;
|
||||
const newDomain:DomainType = data.domain;
|
||||
toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' });
|
||||
onSuccess(false);
|
||||
if (newDomain && newDomain.slug) {
|
||||
@@ -78,7 +78,7 @@ export function useUpdateDomain(onSuccess:Function) {
|
||||
|
||||
export function useDeleteDomain(onSuccess:Function) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(async (domain:Domain) => {
|
||||
return useMutation(async (domain:DomainType) => {
|
||||
const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, { method: 'DELETE' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
|
||||
@@ -2,21 +2,13 @@ import toast from 'react-hot-toast';
|
||||
import { NextRouter } from 'next/router';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
type KeywordsInput = {
|
||||
keywords: string,
|
||||
device: string,
|
||||
country: string,
|
||||
domain: string,
|
||||
tags: string,
|
||||
}
|
||||
|
||||
export const fetchKeywords = async (router: NextRouter) => {
|
||||
if (!router.query.slug) { return []; }
|
||||
const res = await fetch(`${window.location.origin}/api/keywords?domain=${router.query.slug}`, { method: 'GET' });
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval:Function, keywordSPollInterval:undefined|number = undefined) {
|
||||
export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval?:Function, keywordSPollInterval:undefined|number = undefined) {
|
||||
const { data: keywordsData, isLoading: keywordsLoading, isError } = useQuery(
|
||||
['keywords', router.query.slug],
|
||||
() => fetchKeywords(router),
|
||||
@@ -44,9 +36,9 @@ export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval:Fun
|
||||
|
||||
export function useAddKeywords(onSuccess:Function) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(async (newKeywords:KeywordsInput) => {
|
||||
return useMutation(async (keywords:KeywordAddPayload[]) => {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' });
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify(newKeywords) };
|
||||
const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ keywords }) };
|
||||
const res = await fetch(`${window.location.origin}/api/keywords`, fetchOpts);
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
throw new Error('Bad response from server');
|
||||
|
||||
38
services/searchConsole.ts
Normal file
38
services/searchConsole.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRouter } from 'next/router';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
export async function fetchSCKeywords(router: NextRouter) {
|
||||
// if (!router.query.slug) { throw new Error('Invalid Domain Name'); }
|
||||
const res = await fetch(`${window.location.origin}/api/searchconsole?domain=${router.query.slug}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useFetchSCKeywords(router: NextRouter, domainLoaded: boolean = false) {
|
||||
// console.log('ROUTER: ', router);
|
||||
return useQuery('sckeywords', () => router.query.slug && fetchSCKeywords(router), { enabled: domainLoaded });
|
||||
}
|
||||
|
||||
export async function fetchSCInsight(router: NextRouter) {
|
||||
// if (!router.query.slug) { throw new Error('Invalid Domain Name'); }
|
||||
const res = await fetch(`${window.location.origin}/api/insight?domain=${router.query.slug}`, { method: 'GET' });
|
||||
if (res.status >= 400 && res.status < 600) {
|
||||
if (res.status === 401) {
|
||||
console.log('Unauthorized!!');
|
||||
router.push('/login');
|
||||
}
|
||||
throw new Error('Bad response from server');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useFetchSCInsight(router: NextRouter, domainLoaded: boolean = false) {
|
||||
// console.log('ROUTER: ', router);
|
||||
return useQuery('scinsight', () => router.query.slug && fetchSCInsight(router), { enabled: domainLoaded });
|
||||
}
|
||||
@@ -38,4 +38,27 @@ const useUpdateSettings = (onSuccess:Function|undefined) => {
|
||||
});
|
||||
};
|
||||
|
||||
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: '⚠️' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useUpdateSettings;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid #eaeaea;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card,
|
||||
.footer {
|
||||
border-color: #222;
|
||||
}
|
||||
.code {
|
||||
background: #111;
|
||||
}
|
||||
.logo img {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
513
styles/fflag.css
513
styles/fflag.css
@@ -1,7 +1,6 @@
|
||||
|
||||
.fflag {
|
||||
background-image:url('../public/flagSprite42.png');
|
||||
background-repeat:no-repeat;
|
||||
background-image: url("../public/flagSprite42.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 49494%;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
@@ -9,248 +8,249 @@
|
||||
vertical-align: middle;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.fflag-CH,
|
||||
.fflag-NP {box-shadow: none!important}
|
||||
.fflag-DZ {background-position:center 0.2287%}
|
||||
.fflag-AO {background-position:center 0.4524%}
|
||||
.fflag-BJ {background-position:center 0.6721%}
|
||||
.fflag-BW {background-position:center 0.8958%}
|
||||
.fflag-BF {background-position:center 1.1162%}
|
||||
.fflag-BI {background-position:center 1.3379%}
|
||||
.fflag-CM {background-position:center 1.5589%}
|
||||
.fflag-CV {background-position:center 1.7805%}
|
||||
.fflag-CF {background-position:center 2.0047%}
|
||||
.fflag-TD {background-position:center 2.2247%}
|
||||
.fflag-CD {background-position:left 2.4467%}
|
||||
.fflag-DJ {background-position:left 2.6674%}
|
||||
.fflag-EG {background-position:center 2.8931%}
|
||||
.fflag-GQ {background-position:center 3.1125%}
|
||||
.fflag-ER {background-position:left 3.3325%}
|
||||
.fflag-ET {background-position:center 3.5542%}
|
||||
.fflag-GA {background-position:center 3.7759%}
|
||||
.fflag-GM {background-position:center 4.0015%}
|
||||
.fflag-GH {background-position:center 4.2229%}
|
||||
.fflag-GN {background-position:center 4.441%}
|
||||
.fflag-GW {background-position:left 4.66663%}
|
||||
.fflag-CI {background-position:center 4.8844%}
|
||||
.fflag-KE {background-position:center 5.1061%}
|
||||
.fflag-LS {background-position:center 5.3298%}
|
||||
.fflag-LR {background-position:left 5.5495%}
|
||||
.fflag-LY {background-position:center 5.7712%}
|
||||
.fflag-MG {background-position:center 5.994%}
|
||||
.fflag-MW {background-position:center 6.2156%}
|
||||
.fflag-ML {background-position:center 6.4363%}
|
||||
.fflag-MR {background-position:center 6.658%}
|
||||
.fflag-MU {background-position:center 6.8805%}
|
||||
.fflag-YT {background-position:center 7.1038%}
|
||||
.fflag-MA {background-position:center 7.3231%}
|
||||
.fflag-MZ {background-position:left 7.5448%}
|
||||
.fflag-NA {background-position:left 7.7661%}
|
||||
.fflag-NE {background-position:center 7.98937%}
|
||||
.fflag-NG {background-position:center 8.2099%}
|
||||
.fflag-CG {background-position:center 8.4316%}
|
||||
.fflag-RE {background-position:center 8.6533%}
|
||||
.fflag-RW {background-position:right 8.875%}
|
||||
.fflag-SH {background-position:center 9.0967%}
|
||||
.fflag-ST {background-position:center 9.32237%}
|
||||
.fflag-SN {background-position:center 9.5426%}
|
||||
.fflag-SC {background-position:left 9.7628%}
|
||||
.fflag-SL {background-position:center 9.9845%}
|
||||
.fflag-SO {background-position:center 10.2052%}
|
||||
.fflag-ZA {background-position:left 10.4269%}
|
||||
.fflag-SS {background-position:left 10.6486%}
|
||||
.fflag-SD {background-position:center 10.8703%}
|
||||
.fflag-SR {background-position:center 11.0945%}
|
||||
.fflag-SZ {background-position:center 11.3135%}
|
||||
.fflag-TG {background-position:left 11.5354%}
|
||||
.fflag-TN {background-position:center 11.7593%}
|
||||
.fflag-UG {background-position:center 11.9799%}
|
||||
.fflag-TZ {background-position:center 12.2005%}
|
||||
.fflag-EH {background-position:center 12.4222%}
|
||||
.fflag-YE {background-position:center 12.644%}
|
||||
.fflag-ZM {background-position:center 12.8664%}
|
||||
.fflag-ZW {background-position:left 13.0873%}
|
||||
.fflag-AI {background-position:center 13.309%}
|
||||
.fflag-AG {background-position:center 13.5307%}
|
||||
.fflag-AR {background-position:center 13.7524%}
|
||||
.fflag-AW {background-position:left 13.9741%}
|
||||
.fflag-BS {background-position:left 14.1958%}
|
||||
.fflag-BB {background-position:center 14.4175%}
|
||||
.fflag-BQ {background-position:center 14.6415%}
|
||||
.fflag-BZ {background-position:center 14.8609%}
|
||||
.fflag-BM {background-position:center 15.0826%}
|
||||
.fflag-BO {background-position:center 15.306%}
|
||||
.fflag-VG {background-position:center 15.528%}
|
||||
.fflag-BR {background-position:center 15.7496%}
|
||||
.fflag-CA {background-position:center 15.9694%}
|
||||
.fflag-KY {background-position:center 16.1911%}
|
||||
.fflag-CL {background-position:left 16.4128%}
|
||||
.fflag-CO {background-position:left 16.6345%}
|
||||
.fflag-KM {background-position:center 16.8562%}
|
||||
.fflag-CR {background-position:center 17.0779%}
|
||||
.fflag-CU {background-position:left 17.2996%}
|
||||
.fflag-CW {background-position:center 17.5213%}
|
||||
.fflag-DM {background-position:center 17.743%}
|
||||
.fflag-DO {background-position:center 17.968%}
|
||||
.fflag-EC {background-position:center 18.1864%}
|
||||
.fflag-SV {background-position:center 18.4081%}
|
||||
.fflag-FK {background-position:center 18.6298%}
|
||||
.fflag-GF {background-position:center 18.8515%}
|
||||
.fflag-GL {background-position:left 19.0732%}
|
||||
.fflag-GD {background-position:center 19.2987%}
|
||||
.fflag-GP {background-position:center 19.518%}
|
||||
.fflag-GT {background-position:center 19.7383%}
|
||||
.fflag-GY {background-position:center 19.96%}
|
||||
.fflag-HT {background-position:center 20.1817%}
|
||||
.fflag-HN {background-position:center 20.4034%}
|
||||
.fflag-JM {background-position:center 20.6241%}
|
||||
.fflag-MQ {background-position:center 20.8468%}
|
||||
.fflag-MX {background-position:center 21.0685%}
|
||||
.fflag-MS {background-position:center 21.2902%}
|
||||
.fflag-NI {background-position:center 21.5119%}
|
||||
.fflag-PA {background-position:center 21.7336%}
|
||||
.fflag-PY {background-position:center 21.9553%}
|
||||
.fflag-PE {background-position:center 22.177%}
|
||||
.fflag-PR {background-position:left 22.4002%}
|
||||
.fflag-BL {background-position:center 22.6204%}
|
||||
.fflag-KN {background-position:center 22.8421%}
|
||||
.fflag-LC {background-position:center 23.0638%}
|
||||
.fflag-PM {background-position:center 23.2855%}
|
||||
.fflag-VC {background-position:center 23.5072%}
|
||||
.fflag-SX {background-position:left 23.732%}
|
||||
.fflag-TT {background-position:center 23.9506%}
|
||||
.fflag-TC {background-position:center 24.1723%}
|
||||
.fflag-US {background-position:center 24.394%}
|
||||
.fflag-VI {background-position:center 24.6157%}
|
||||
.fflag-UY {background-position:left 24.8374%}
|
||||
.fflag-VE {background-position:center 25.0591%}
|
||||
.fflag-AB {background-position:center 25.279%}
|
||||
.fflag-AF {background-position:center 25.5025%}
|
||||
.fflag-AZ {background-position:center 25.7242%}
|
||||
.fflag-BD {background-position:center 25.9459%}
|
||||
.fflag-BT {background-position:center 26.1676%}
|
||||
.fflag-BN {background-position:center 26.3885%}
|
||||
.fflag-KH {background-position:center 26.611%}
|
||||
.fflag-CN {background-position:left 26.8327%}
|
||||
.fflag-GE {background-position:center 27.0544%}
|
||||
.fflag-HK {background-position:center 27.2761%}
|
||||
.fflag-IN {background-position:center 27.4978%}
|
||||
.fflag-ID {background-position:center 27.7195%}
|
||||
.fflag-JP {background-position:center 27.9412%}
|
||||
.fflag-KZ {background-position:center 28.1615%}
|
||||
.fflag-LA {background-position:center 28.3846%}
|
||||
.fflag-MO {background-position:center 28.6063%}
|
||||
.fflag-MY {background-position:center 28.829%}
|
||||
.fflag-MV {background-position:center 29.0497%}
|
||||
.fflag-MN {background-position:left 29.2714%}
|
||||
.fflag-MM {background-position:center 29.4931%}
|
||||
.fflag-NP {background-position:left 29.7148%}
|
||||
.fflag-KP {background-position:left 29.9365%}
|
||||
.fflag-MP {background-position:center 30.1582%}
|
||||
.fflag-PW {background-position:center 30.3799%}
|
||||
.fflag-PG {background-position:center 30.6016%}
|
||||
.fflag-PH {background-position:left 30.8233%}
|
||||
.fflag-SG {background-position:left 31.045%}
|
||||
.fflag-KR {background-position:center 31.2667%}
|
||||
.fflag-LK {background-position:right 31.4884%}
|
||||
.fflag-TW {background-position:left 31.7101%}
|
||||
.fflag-TJ {background-position:center 31.9318%}
|
||||
.fflag-TH {background-position:center 32.1535%}
|
||||
.fflag-TL {background-position:left 32.3752%}
|
||||
.fflag-TM {background-position:center 32.5969%}
|
||||
.fflag-VN {background-position:center 32.8186%}
|
||||
.fflag-AL {background-position:center 33.0403%}
|
||||
.fflag-AD {background-position:center 33.25975%}
|
||||
.fflag-AM {background-position:center 33.4837%}
|
||||
.fflag-AT {background-position:center 33.7054%}
|
||||
.fflag-BY {background-position:left 33.9271%}
|
||||
.fflag-BE {background-position:center 34.1488%}
|
||||
.fflag-BA {background-position:center 34.3705%}
|
||||
.fflag-BG {background-position:center 34.5922%}
|
||||
.fflag-HR {background-position:center 34.8139%}
|
||||
.fflag-CY {background-position:center 35.0356%}
|
||||
.fflag-CZ {background-position:left 35.2555%}
|
||||
.fflag-DK {background-position:center 35.479%}
|
||||
.fflag-EE {background-position:center 35.7007%}
|
||||
.fflag-FO {background-position:center 35.9224%}
|
||||
.fflag-FI {background-position:center 36.1441%}
|
||||
.fflag-FR {background-position:center 36.3658%}
|
||||
.fflag-DE {background-position:center 36.5875%}
|
||||
.fflag-GI {background-position:center 36.8092%}
|
||||
.fflag-GR {background-position:left 37.0309%}
|
||||
.fflag-GG {background-position:center 37.2526%}
|
||||
.fflag-HU {background-position:center 37.4743%}
|
||||
.fflag-IS {background-position:center 37.696%}
|
||||
.fflag-IE {background-position:center 37.9177%}
|
||||
.fflag-IM {background-position:center 38.1394%}
|
||||
.fflag-IT {background-position:center 38.3611%}
|
||||
.fflag-JE {background-position:center 38.5828%}
|
||||
.fflag-XK {background-position:center 38.8045%}
|
||||
.fflag-LV {background-position:center 39.0262%}
|
||||
.fflag-LI {background-position:left 39.2479%}
|
||||
.fflag-LT {background-position:center 39.4696%}
|
||||
.fflag-LU {background-position:center 39.6913%}
|
||||
.fflag-MT {background-position:left 39.913%}
|
||||
.fflag-MD {background-position:center 40.1347%}
|
||||
.fflag-MC {background-position:center 40.3564%}
|
||||
.fflag-ME {background-position:center 40.5781%}
|
||||
.fflag-NL {background-position:center 40.7998%}
|
||||
.fflag-MK {background-position:center 41.0215%}
|
||||
.fflag-NO {background-position:center 41.2432%}
|
||||
.fflag-PL {background-position:center 41.4649%}
|
||||
.fflag-PT {background-position:center 41.6866%}
|
||||
.fflag-RO {background-position:center 41.9083%}
|
||||
.fflag-RU {background-position:center 42.13%}
|
||||
.fflag-SM {background-position:center 42.3517%}
|
||||
.fflag-RS {background-position:center 42.5734%}
|
||||
.fflag-SK {background-position:center 42.7951%}
|
||||
.fflag-SI {background-position:center 43.0168%}
|
||||
.fflag-ES {background-position:left 43.2385%}
|
||||
.fflag-SE {background-position:center 43.4602%}
|
||||
.fflag-CH {background-position:center 43.6819%}
|
||||
.fflag-TR {background-position:center 43.9036%}
|
||||
.fflag-UA {background-position:center 44.1253%}
|
||||
.fflag-GB {background-position:center 44.347%}
|
||||
.fflag-VA {background-position:right 44.5687%}
|
||||
.fflag-BH {background-position:center 44.7904%}
|
||||
.fflag-IR {background-position:center 45.0121%}
|
||||
.fflag-IQ {background-position:center 45.2338%}
|
||||
.fflag-IL {background-position:center 45.4555%}
|
||||
.fflag-KW {background-position:left 45.6772%}
|
||||
.fflag-JO {background-position:left 45.897%}
|
||||
.fflag-KG {background-position:center 46.1206%}
|
||||
.fflag-LB {background-position:center 46.3423%}
|
||||
.fflag-OM {background-position:left 46.561%}
|
||||
.fflag-PK {background-position:center 46.7857%}
|
||||
.fflag-PS {background-position:center 47.0074%}
|
||||
.fflag-QA {background-position:center 47.2291%}
|
||||
.fflag-SA {background-position:center 47.4508%}
|
||||
.fflag-SY {background-position:center 47.6725%}
|
||||
.fflag-AE {background-position:center 47.8942%}
|
||||
.fflag-UZ {background-position:left 48.1159%}
|
||||
.fflag-AS {background-position:right 48.3376%}
|
||||
.fflag-AU {background-position:center 48.5593%}
|
||||
.fflag-CX {background-position:center 48.781%}
|
||||
.fflag-CC {background-position:center 49.002%}
|
||||
.fflag-CK {background-position:center 49.2244%}
|
||||
.fflag-FJ {background-position:center 49.4445%}
|
||||
.fflag-PF {background-position:center 49.6678%}
|
||||
.fflag-GU {background-position:center 49.8895%}
|
||||
.fflag-KI {background-position:center 50.1112%}
|
||||
.fflag-MH {background-position:left 50.3329%}
|
||||
.fflag-FM {background-position:center 50.5546%}
|
||||
.fflag-NC {background-position:center 50.7763%}
|
||||
.fflag-NZ {background-position:center 50.998%}
|
||||
.fflag-NR {background-position:left 51.2197%}
|
||||
.fflag-NU {background-position:center 51.4414%}
|
||||
.fflag-NF {background-position:center 51.6631%}
|
||||
.fflag-WS {background-position:left 51.8848%}
|
||||
.fflag-SB {background-position:left 52.1065%}
|
||||
.fflag-TK {background-position:center 52.3282%}
|
||||
.fflag-TO {background-position:left 52.5499%}
|
||||
.fflag-TV {background-position:center 52.7716%}
|
||||
.fflag-VU {background-position:left 52.9933%}
|
||||
.fflag-WF {background-position:center 53.215%}
|
||||
.fflag-DZ { background-position: center 0.2287%; }
|
||||
.fflag-AO { background-position: center 0.4524%; }
|
||||
.fflag-BJ { background-position: center 0.6721%; }
|
||||
.fflag-BW { background-position: center 0.8958%; }
|
||||
.fflag-BF { background-position: center 1.1162%; }
|
||||
.fflag-BI { background-position: center 1.3379%; }
|
||||
.fflag-CM { background-position: center 1.5589%; }
|
||||
.fflag-CV { background-position: center 1.7805%; }
|
||||
.fflag-CF { background-position: center 2.0047%; }
|
||||
.fflag-TD { background-position: center 2.2247%; }
|
||||
.fflag-CD { background-position: left 2.4467%; }
|
||||
.fflag-DJ { background-position: left 2.6674%; }
|
||||
.fflag-EG { background-position: center 2.8931%; }
|
||||
.fflag-GQ { background-position: center 3.1125%; }
|
||||
.fflag-ER { background-position: left 3.3325%; }
|
||||
.fflag-ET { background-position: center 3.5542%; }
|
||||
.fflag-GA { background-position: center 3.7759%; }
|
||||
.fflag-GM { background-position: center 4.0015%; }
|
||||
.fflag-GH { background-position: center 4.2229%; }
|
||||
.fflag-GN { background-position: center 4.441%; }
|
||||
.fflag-GW { background-position: left 4.6666%; }
|
||||
.fflag-CI { background-position: center 4.8844%; }
|
||||
.fflag-KE { background-position: center 5.1061%; }
|
||||
.fflag-LS { background-position: center 5.3298%; }
|
||||
.fflag-LR { background-position: left 5.5495%; }
|
||||
.fflag-LY { background-position: center 5.7712%; }
|
||||
.fflag-MG { background-position: center 5.994%; }
|
||||
.fflag-MW { background-position: center 6.2156%; }
|
||||
.fflag-ML { background-position: center 6.4363%; }
|
||||
.fflag-MR { background-position: center 6.658%; }
|
||||
.fflag-MU { background-position: center 6.8805%; }
|
||||
.fflag-YT { background-position: center 7.1038%; }
|
||||
.fflag-MA { background-position: center 7.3231%; }
|
||||
.fflag-MZ { background-position: left 7.5448%; }
|
||||
.fflag-NA { background-position: left 7.7661%; }
|
||||
.fflag-NE { background-position: center 7.9894%; }
|
||||
.fflag-NG { background-position: center 8.2099%; }
|
||||
.fflag-CG { background-position: center 8.4316%; }
|
||||
.fflag-RE { background-position: center 8.6533%; }
|
||||
.fflag-RW { background-position: right 8.875%; }
|
||||
.fflag-SH { background-position: center 9.0967%; }
|
||||
.fflag-ST { background-position: center 9.3224%; }
|
||||
.fflag-SN { background-position: center 9.5426%; }
|
||||
.fflag-SC { background-position: left 9.7628%; }
|
||||
.fflag-SL { background-position: center 9.9845%; }
|
||||
.fflag-SO { background-position: center 10.2052%; }
|
||||
.fflag-ZA { background-position: left 10.4269%; }
|
||||
.fflag-SS { background-position: left 10.6486%; }
|
||||
.fflag-SD { background-position: center 10.8703%; }
|
||||
.fflag-SR { background-position: center 11.0945%; }
|
||||
.fflag-SZ { background-position: center 11.3135%; }
|
||||
.fflag-TG { background-position: left 11.5354%; }
|
||||
.fflag-TN { background-position: center 11.7593%; }
|
||||
.fflag-UG { background-position: center 11.9799%; }
|
||||
.fflag-TZ { background-position: center 12.2005%; }
|
||||
.fflag-EH { background-position: center 12.4222%; }
|
||||
.fflag-YE { background-position: center 12.644%; }
|
||||
.fflag-ZM { background-position: center 12.8664%; }
|
||||
.fflag-ZW { background-position: left 13.0873%; }
|
||||
.fflag-AI { background-position: center 13.309%; }
|
||||
.fflag-AG { background-position: center 13.5307%; }
|
||||
.fflag-AR { background-position: center 13.7524%; }
|
||||
.fflag-AW { background-position: left 13.9741%; }
|
||||
.fflag-BS { background-position: left 14.1958%; }
|
||||
.fflag-BB { background-position: center 14.4175%; }
|
||||
.fflag-BQ { background-position: center 14.6415%; }
|
||||
.fflag-BZ { background-position: center 14.8609%; }
|
||||
.fflag-BM { background-position: center 15.0826%; }
|
||||
.fflag-BO { background-position: center 15.306%; }
|
||||
.fflag-VG { background-position: center 15.528%; }
|
||||
.fflag-BR { background-position: center 15.7496%; }
|
||||
.fflag-CA { background-position: center 15.9694%; }
|
||||
.fflag-KY { background-position: center 16.1911%; }
|
||||
.fflag-CL { background-position: left 16.4128%; }
|
||||
.fflag-CO { background-position: left 16.6345%; }
|
||||
.fflag-KM { background-position: center 16.8562%; }
|
||||
.fflag-CR { background-position: center 17.0779%; }
|
||||
.fflag-CU { background-position: left 17.2996%; }
|
||||
.fflag-CW { background-position: center 17.5213%; }
|
||||
.fflag-DM { background-position: center 17.743%; }
|
||||
.fflag-DO { background-position: center 17.968%; }
|
||||
.fflag-EC { background-position: center 18.1864%; }
|
||||
.fflag-SV { background-position: center 18.4081%; }
|
||||
.fflag-FK { background-position: center 18.6298%; }
|
||||
.fflag-GF { background-position: center 18.8515%; }
|
||||
.fflag-GL { background-position: left 19.0732%; }
|
||||
.fflag-GD { background-position: center 19.2987%; }
|
||||
.fflag-GP { background-position: center 19.518%; }
|
||||
.fflag-GT { background-position: center 19.7383%; }
|
||||
.fflag-GY { background-position: center 19.96%; }
|
||||
.fflag-HT { background-position: center 20.1817%; }
|
||||
.fflag-HN { background-position: center 20.4034%; }
|
||||
.fflag-JM { background-position: center 20.6241%; }
|
||||
.fflag-MQ { background-position: center 20.8468%; }
|
||||
.fflag-MX { background-position: center 21.0685%; }
|
||||
.fflag-MS { background-position: center 21.2902%; }
|
||||
.fflag-NI { background-position: center 21.5119%; }
|
||||
.fflag-PA { background-position: center 21.7336%; }
|
||||
.fflag-PY { background-position: center 21.9553%; }
|
||||
.fflag-PE { background-position: center 22.177%; }
|
||||
.fflag-PR { background-position: left 22.4002%; }
|
||||
.fflag-BL { background-position: center 22.6204%; }
|
||||
.fflag-KN { background-position: center 22.8421%; }
|
||||
.fflag-LC { background-position: center 23.0638%; }
|
||||
.fflag-PM { background-position: center 23.2855%; }
|
||||
.fflag-VC { background-position: center 23.5072%; }
|
||||
.fflag-SX { background-position: left 23.732%; }
|
||||
.fflag-TT { background-position: center 23.9506%; }
|
||||
.fflag-TC { background-position: center 24.1723%; }
|
||||
.fflag-US { background-position: center 24.394%; }
|
||||
.fflag-VI { background-position: center 24.6157%; }
|
||||
.fflag-UY { background-position: left 24.8374%; }
|
||||
.fflag-VE { background-position: center 25.0591%; }
|
||||
.fflag-AB { background-position: center 25.279%; }
|
||||
.fflag-AF { background-position: center 25.5025%; }
|
||||
.fflag-AZ { background-position: center 25.7242%; }
|
||||
.fflag-BD { background-position: center 25.9459%; }
|
||||
.fflag-BT { background-position: center 26.1676%; }
|
||||
.fflag-BN { background-position: center 26.3885%; }
|
||||
.fflag-KH { background-position: center 26.611%; }
|
||||
.fflag-CN { background-position: left 26.8327%; }
|
||||
.fflag-GE { background-position: center 27.0544%; }
|
||||
.fflag-HK { background-position: center 27.2761%; }
|
||||
.fflag-IN { background-position: center 27.4978%; }
|
||||
.fflag-ID { background-position: center 27.7195%; }
|
||||
.fflag-JP { background-position: center 27.9412%; }
|
||||
.fflag-KZ { background-position: center 28.1615%; }
|
||||
.fflag-LA { background-position: center 28.3846%; }
|
||||
.fflag-MO { background-position: center 28.6063%; }
|
||||
.fflag-MY { background-position: center 28.829%; }
|
||||
.fflag-MV { background-position: center 29.0497%; }
|
||||
.fflag-MN { background-position: left 29.2714%; }
|
||||
.fflag-MM { background-position: center 29.4931%; }
|
||||
.fflag-NP { background-position: left 29.7148%; }
|
||||
.fflag-KP { background-position: left 29.9365%; }
|
||||
.fflag-MP { background-position: center 30.1582%; }
|
||||
.fflag-PW { background-position: center 30.3799%; }
|
||||
.fflag-PG { background-position: center 30.6016%; }
|
||||
.fflag-PH { background-position: left 30.8233%; }
|
||||
.fflag-SG { background-position: left 31.045%; }
|
||||
.fflag-KR { background-position: center 31.2667%; }
|
||||
.fflag-LK { background-position: right 31.4884%; }
|
||||
.fflag-TW { background-position: left 31.7101%; }
|
||||
.fflag-TJ { background-position: center 31.9318%; }
|
||||
.fflag-TH { background-position: center 32.1535%; }
|
||||
.fflag-TL { background-position: left 32.3752%; }
|
||||
.fflag-TM { background-position: center 32.5969%; }
|
||||
.fflag-VN { background-position: center 32.8186%; }
|
||||
.fflag-AL { background-position: center 33.0403%; }
|
||||
.fflag-AD { background-position: center 33.2597%; }
|
||||
.fflag-AM { background-position: center 33.4837%; }
|
||||
.fflag-AT { background-position: center 33.7054%; }
|
||||
.fflag-BY { background-position: left 33.9271%; }
|
||||
.fflag-BE { background-position: center 34.1488%; }
|
||||
.fflag-BA { background-position: center 34.3705%; }
|
||||
.fflag-BG { background-position: center 34.5922%; }
|
||||
.fflag-HR { background-position: center 34.8139%; }
|
||||
.fflag-CY { background-position: center 35.0356%; }
|
||||
.fflag-CZ { background-position: left 35.2555%; }
|
||||
.fflag-DK { background-position: center 35.479%; }
|
||||
.fflag-EE { background-position: center 35.7007%; }
|
||||
.fflag-FO { background-position: center 35.9224%; }
|
||||
.fflag-FI { background-position: center 36.1441%; }
|
||||
.fflag-FR { background-position: center 36.3658%; }
|
||||
.fflag-DE { background-position: center 36.5875%; }
|
||||
.fflag-GI { background-position: center 36.8092%; }
|
||||
.fflag-GR { background-position: left 37.0309%; }
|
||||
.fflag-GG { background-position: center 37.2526%; }
|
||||
.fflag-HU { background-position: center 37.4743%; }
|
||||
.fflag-IS { background-position: center 37.696%; }
|
||||
.fflag-IE { background-position: center 37.9177%; }
|
||||
.fflag-IM { background-position: center 38.1394%; }
|
||||
.fflag-IT { background-position: center 38.3611%; }
|
||||
.fflag-JE { background-position: center 38.5828%; }
|
||||
.fflag-XK { background-position: center 38.8045%; }
|
||||
.fflag-LV { background-position: center 39.0262%; }
|
||||
.fflag-LI { background-position: left 39.2479%; }
|
||||
.fflag-LT { background-position: center 39.4696%; }
|
||||
.fflag-LU { background-position: center 39.6913%; }
|
||||
.fflag-MT { background-position: left 39.913%; }
|
||||
.fflag-MD { background-position: center 40.1347%; }
|
||||
.fflag-MC { background-position: center 40.3564%; }
|
||||
.fflag-ME { background-position: center 40.5781%; }
|
||||
.fflag-NL { background-position: center 40.7998%; }
|
||||
.fflag-MK { background-position: center 41.0215%; }
|
||||
.fflag-NO { background-position: center 41.2432%; }
|
||||
.fflag-PL { background-position: center 41.4649%; }
|
||||
.fflag-PT { background-position: center 41.6866%; }
|
||||
.fflag-RO { background-position: center 41.9083%; }
|
||||
.fflag-RU { background-position: center 42.13%; }
|
||||
.fflag-SM { background-position: center 42.3517%; }
|
||||
.fflag-RS { background-position: center 42.5734%; }
|
||||
.fflag-SK { background-position: center 42.7951%; }
|
||||
.fflag-SI { background-position: center 43.0168%; }
|
||||
.fflag-ES { background-position: left 43.2385%; }
|
||||
.fflag-SE { background-position: center 43.4602%; }
|
||||
.fflag-CH { background-position: center 43.6819%; }
|
||||
.fflag-TR { background-position: center 43.9036%; }
|
||||
.fflag-UA { background-position: center 44.1253%; }
|
||||
.fflag-GB { background-position: center 44.347%; }
|
||||
.fflag-VA { background-position: right 44.5687%; }
|
||||
.fflag-BH { background-position: center 44.7904%; }
|
||||
.fflag-IR { background-position: center 45.0121%; }
|
||||
.fflag-IQ { background-position: center 45.2338%; }
|
||||
.fflag-IL { background-position: center 45.4555%; }
|
||||
.fflag-KW { background-position: left 45.6772%; }
|
||||
.fflag-JO { background-position: left 45.897%; }
|
||||
.fflag-KG { background-position: center 46.1206%; }
|
||||
.fflag-LB { background-position: center 46.3423%; }
|
||||
.fflag-OM { background-position: left 46.561%; }
|
||||
.fflag-PK { background-position: center 46.7857%; }
|
||||
.fflag-PS { background-position: center 47.0074%; }
|
||||
.fflag-QA { background-position: center 47.2291%; }
|
||||
.fflag-SA { background-position: center 47.4508%; }
|
||||
.fflag-SY { background-position: center 47.6725%; }
|
||||
.fflag-AE { background-position: center 47.8942%; }
|
||||
.fflag-UZ { background-position: left 48.1159%; }
|
||||
.fflag-AS { background-position: right 48.3376%; }
|
||||
.fflag-AU { background-position: center 48.5593%; }
|
||||
.fflag-CX { background-position: center 48.781%; }
|
||||
.fflag-CC { background-position: center 49.002%; }
|
||||
.fflag-CK { background-position: center 49.2244%; }
|
||||
.fflag-FJ { background-position: center 49.4445%; }
|
||||
.fflag-PF { background-position: center 49.6678%; }
|
||||
.fflag-GU { background-position: center 49.8895%; }
|
||||
.fflag-KI { background-position: center 50.1112%; }
|
||||
.fflag-MH { background-position: left 50.3329%; }
|
||||
.fflag-FM { background-position: center 50.5546%; }
|
||||
.fflag-NC { background-position: center 50.7763%; }
|
||||
.fflag-NZ { background-position: center 50.998%; }
|
||||
.fflag-NR { background-position: left 51.2197%; }
|
||||
.fflag-NU { background-position: center 51.4414%; }
|
||||
.fflag-NF { background-position: center 51.6631%; }
|
||||
.fflag-WS { background-position: left 51.8848%; }
|
||||
.fflag-SB { background-position: left 52.1065%; }
|
||||
.fflag-TK { background-position: center 52.3282%; }
|
||||
.fflag-TO { background-position: left 52.5499%; }
|
||||
.fflag-TV { background-position: center 52.7716%; }
|
||||
.fflag-VU { background-position: left 52.9933%; }
|
||||
.fflag-WF { background-position: center 53.215%; }
|
||||
|
||||
.fflag-TD.ff-round,
|
||||
.fflag-GN.ff-round,
|
||||
.fflag-CI.ff-round,
|
||||
@@ -260,20 +260,21 @@
|
||||
.fflag-FR.ff-round,
|
||||
.fflag-IE.ff-round,
|
||||
.fflag-IT.ff-round,
|
||||
.fflag-RO.ff-round {background-size:100% 50000%}
|
||||
.fflag-RO.ff-round { background-size: 100% 50000%; }
|
||||
|
||||
.fflag.ff-sm {width: 18px;height: 11px}
|
||||
.fflag.ff-md {width: 27px;height: 17px}
|
||||
.fflag.ff-lg {width: 42px;height: 27px}
|
||||
.fflag.ff-xl {width: 60px;height: 37px}
|
||||
.fflag.ff-sm { width: 18px; height: 11px }
|
||||
.fflag.ff-md { width: 27px; height: 17px }
|
||||
.fflag.ff-lg { width: 42px; height: 27px }
|
||||
.fflag.ff-xl { width: 60px; height: 37px }
|
||||
|
||||
/* ff-round = circular icons */
|
||||
.ff-round {
|
||||
background-size: 160%;
|
||||
background-clip: content-box;
|
||||
border-radius: 50%;
|
||||
background-size: 160%;
|
||||
background-clip: content-box;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ff-round.ff-sm {width: 12px; height: 12px}
|
||||
.ff-round.ff-md {width: 18px; height: 18px}
|
||||
.ff-round.ff-lg {width: 24px; height: 24px}
|
||||
.ff-round.ff-xl {width: 32px; height: 32px}
|
||||
|
||||
.ff-round.ff-sm { width: 12px; height: 12px }
|
||||
.ff-round.ff-md { width: 18px; height: 18px }
|
||||
.ff-round.ff-lg { width: 24px; height: 24px }
|
||||
.ff-round.ff-xl { width: 32px; height: 32px }
|
||||
|
||||
@@ -1,114 +1,139 @@
|
||||
@import url("./fflag.css");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import url('./fflag.css');
|
||||
|
||||
body{
|
||||
body {
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.domKeywords{
|
||||
min-height: 70vh;
|
||||
border-color: #E9EBFF;
|
||||
box-shadow: 0 0 20px rgba(20, 34, 71, 0.05);
|
||||
.domKeywords {
|
||||
/* min-height: 70vh; */
|
||||
border-color: #e9ebff;
|
||||
box-shadow: 0 0 20px rgb(20 34 71 / 5%);
|
||||
}
|
||||
|
||||
|
||||
.customShadow{
|
||||
border-color: #E9EBFF;
|
||||
box-shadow: 0 0 20px rgba(20, 34, 71, 0.05);
|
||||
.customShadow {
|
||||
border-color: #e9ebff;
|
||||
box-shadow: 0 0 20px rgb(20 34 71 / 5%);
|
||||
}
|
||||
|
||||
.styled-scrollbar {
|
||||
scrollbar-color: #d6dbec transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 0px;
|
||||
border-radius: 0;
|
||||
background: #f5f7ff;
|
||||
margin-right: 4px;
|
||||
border: 0px solid transparent;
|
||||
border: 0 solid transparent;
|
||||
}
|
||||
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 0px;
|
||||
border-radius: 0;
|
||||
color: #d6dbec;
|
||||
background: #d6d8e1;
|
||||
border: 0px solid transparent;
|
||||
background: #d6d8e1;
|
||||
border: 0 solid transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ct-area {
|
||||
fill: #10b98d73;
|
||||
}
|
||||
|
||||
.ct-label.ct-horizontal {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ct-label.ct-vertical {
|
||||
font-size: 12px;
|
||||
}
|
||||
.chart_tooltip{
|
||||
width: 95px;
|
||||
height: 75px;
|
||||
|
||||
.chart_tooltip {
|
||||
width: 95px;
|
||||
height: 75px;
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
display: none;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
z-index: 1000;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
pointer-events: none;
|
||||
border: 1px solid;
|
||||
position: absolute;
|
||||
display: none;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
z-index: 1000;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
pointer-events: none;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
border-color: #a7aed3;
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 12%);
|
||||
font-family: 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
|
||||
border-color: #a7aed3;
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 12%);
|
||||
font-family: "Trebuchet MS", Roboto, Ubuntu, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
.react_toaster{
|
||||
.react_toaster {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.domKeywords_head--alpha_desc .domKeywords_head_keyword:after,
|
||||
.domKeywords_head--pos_desc .domKeywords_head_position:after
|
||||
{content: '↓' ; display: inline-block; margin-left: 2px; font-size: 14px; opacity: 0.8;}
|
||||
.domKeywords_head--alpha_desc .domKeywords_head_keyword::after,
|
||||
.domKeywords_head--pos_desc .domKeywords_head_position::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;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.domKeywords_head--alpha_asc .domKeywords_head_keyword:after,
|
||||
.domKeywords_head--pos_asc .domKeywords_head_position:after
|
||||
{content: '↑' ; display: inline-block; margin-left: 2px; font-size: 14px; opacity: 0.8;}
|
||||
.domKeywords_head--alpha_asc .domKeywords_head_keyword::after,
|
||||
.domKeywords_head--pos_asc .domKeywords_head_position::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;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.keywordDetails__section__results{
|
||||
.keywordDetails__section__results {
|
||||
height: calc(100vh - 550px);
|
||||
}
|
||||
|
||||
.settings__content{
|
||||
.settings__content {
|
||||
height: calc(100vh - 185px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Animation */
|
||||
.modal_anim-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal_anim-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 300ms;
|
||||
}
|
||||
.modal_anim-enter .modal__content{
|
||||
|
||||
.modal_anim-enter .modal__content {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
.modal_anim-enter-active .modal__content{
|
||||
|
||||
.modal_anim-enter-active .modal__content {
|
||||
transform: translateY(0);
|
||||
transition: all 300ms;
|
||||
}
|
||||
@@ -116,34 +141,144 @@ body{
|
||||
.modal_anim-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal_anim-exit-active {
|
||||
opacity: 0;
|
||||
transition: all 300ms;
|
||||
}
|
||||
.modal_anim-exit .modal__content{
|
||||
transform: translateY(0px);
|
||||
|
||||
.modal_anim-exit .modal__content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.modal_anim-exit-active .modal__content{
|
||||
|
||||
.modal_anim-exit-active .modal__content {
|
||||
transform: translateY(50px);
|
||||
transition: all 300ms;
|
||||
}
|
||||
|
||||
|
||||
.settings_anim-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(400px);
|
||||
}
|
||||
|
||||
.settings_anim-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition: all 300ms;
|
||||
}
|
||||
|
||||
.settings_anim-exit {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.settings_anim-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateX(400px);
|
||||
transition: all 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.domItem {
|
||||
transition: all 0.15s linear;
|
||||
border-color: #e9ebff;
|
||||
box-shadow: 0 0 20px rgb(20 34 71 / 5%);
|
||||
}
|
||||
|
||||
.domItem:hover h3 {
|
||||
color: #364aff
|
||||
}
|
||||
|
||||
.noDomains {
|
||||
border-color: #e9ebff;
|
||||
box-shadow: 0 0 20px rgb(20 34 71 / 5%);
|
||||
}
|
||||
|
||||
.domItem:hover {
|
||||
border-color: #9499d8;
|
||||
box-shadow: 0 0 20px rgb(30 65 161 / 25%);
|
||||
}
|
||||
|
||||
.domain_selector .selected,
|
||||
.insight_selector .selected {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.domain_selector .select,
|
||||
.insight_selector .select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.domain_selector .select_list,
|
||||
.insight_selector .select_list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.domain_selector .selected > span:nth-child(1),
|
||||
.insight_selector .selected > span:nth-child(1) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dom_sc_stats > div::after,
|
||||
.dom_stats > div::after {
|
||||
content: "";
|
||||
width: 1px;
|
||||
height: 35px;
|
||||
background: #eee;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.dom_sc_stats > div:nth-child(3)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dom_stats > div:nth-child(2)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Domain Header Button Tooltips */
|
||||
.domheader_action_button:hover i {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.domheader_action_button i {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
top: -22px;
|
||||
background: #222;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
padding-bottom: 3px;
|
||||
transition: all 0.2s linear;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.domheader_action_button i::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 5px 5px 0;
|
||||
border-color: #222 transparent transparent;
|
||||
bottom: -5px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.domkeywordsTable--keywords.domkeywordsTable--hasSC .domKeywords_keywords::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: #eff0f1;
|
||||
top: 0;
|
||||
right: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
module.exports = {
|
||||
purge: {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
},
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
safelist: [
|
||||
'max-h-48',
|
||||
'w-[150px]',
|
||||
'w-[240px]',
|
||||
'min-w-[270px]',
|
||||
'min-w-[180px]'
|
||||
'min-w-[180px]',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"experimentalDecorators": true,
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types.d.ts", "utils/generateEmail__.js"],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"types.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
131
types.d.ts
vendored
131
types.d.ts
vendored
@@ -1,15 +1,20 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
type Domain = {
|
||||
type DomainType = {
|
||||
ID: number,
|
||||
domain: string,
|
||||
slug: string,
|
||||
tags?: string[],
|
||||
tags?: string,
|
||||
notification: boolean,
|
||||
notification_interval: string,
|
||||
notification_emails: string,
|
||||
lastUpdated: string,
|
||||
added: string,
|
||||
keywordCount: number
|
||||
keywordCount?: number,
|
||||
keywordsUpdated?: string,
|
||||
avgPosition?: number,
|
||||
scVisits?: number,
|
||||
scImpressions?: number,
|
||||
scPosition?: number,
|
||||
}
|
||||
|
||||
type KeywordHistory = {
|
||||
@@ -31,7 +36,9 @@ type KeywordType = {
|
||||
url: string,
|
||||
tags: string[],
|
||||
updating: boolean,
|
||||
lastUpdateError: {date: string, error: string, scraper: string} | false
|
||||
lastUpdateError: {date: string, error: string, scraper: string} | false,
|
||||
scData?: KeywordSCData,
|
||||
uid?: string
|
||||
}
|
||||
|
||||
type KeywordLastResult = {
|
||||
@@ -47,7 +54,11 @@ type KeywordFilters = {
|
||||
}
|
||||
|
||||
type countryData = {
|
||||
[ISO:string] : string[]
|
||||
[ISO:string] : [countryName:string, cityName:string, language:string]
|
||||
}
|
||||
|
||||
type countryCodeData = {
|
||||
[ISO:string] : string
|
||||
}
|
||||
|
||||
type DomainSettings = {
|
||||
@@ -64,6 +75,112 @@ type SettingsType = {
|
||||
notification_email_from: string,
|
||||
smtp_server: string,
|
||||
smtp_port: string,
|
||||
smtp_username: string,
|
||||
smtp_password: string
|
||||
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
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
yesterday: number,
|
||||
threeDays: number,
|
||||
sevenDays: number,
|
||||
thirtyDays: number,
|
||||
avgSevenDays: number,
|
||||
avgThreeDays: number,
|
||||
avgThirtyDays: number,
|
||||
}
|
||||
type KeywordSCData = {
|
||||
impressions: KeywordSCDataChild,
|
||||
visits: KeywordSCDataChild,
|
||||
ctr: KeywordSCDataChild,
|
||||
position:KeywordSCDataChild
|
||||
}
|
||||
|
||||
type KeywordAddPayload = {
|
||||
keyword: string,
|
||||
device: string,
|
||||
country: string,
|
||||
domain: string,
|
||||
tags: string,
|
||||
}
|
||||
|
||||
type SearchAnalyticsRawItem = {
|
||||
keys: string[],
|
||||
clicks: number,
|
||||
impressions: number,
|
||||
ctr: number,
|
||||
position: number,
|
||||
}
|
||||
|
||||
type SearchAnalyticsStat = {
|
||||
date: string,
|
||||
clicks: number,
|
||||
impressions: number,
|
||||
ctr: number,
|
||||
position: number,
|
||||
}
|
||||
|
||||
type InsightDataType = {
|
||||
stats: SearchAnalyticsStat[]|null,
|
||||
keywords: SCInsightItem[],
|
||||
countries: SCInsightItem[],
|
||||
pages: SCInsightItem[],
|
||||
}
|
||||
|
||||
type SCInsightItem = {
|
||||
clicks: number,
|
||||
impressions: number,
|
||||
ctr: number,
|
||||
position: number,
|
||||
countries?: number,
|
||||
country?: string,
|
||||
keyword?: string,
|
||||
keywords?: number,
|
||||
page?: string,
|
||||
date?: string
|
||||
}
|
||||
|
||||
type SearchAnalyticsItem = {
|
||||
keyword: string,
|
||||
uid: string,
|
||||
device: string,
|
||||
page: string,
|
||||
country: string,
|
||||
clicks: number,
|
||||
impressions: number,
|
||||
ctr: number,
|
||||
position: number,
|
||||
date?: string
|
||||
}
|
||||
|
||||
type SCDomainDataType = {
|
||||
threeDays : SearchAnalyticsItem[],
|
||||
sevenDays : SearchAnalyticsItem[],
|
||||
thirtyDays : SearchAnalyticsItem[],
|
||||
lastFetched?: string,
|
||||
lastFetchError?: string,
|
||||
stats? : SearchAnalyticsStat[],
|
||||
}
|
||||
|
||||
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[],
|
||||
}
|
||||
|
||||
82
utils/SCsortFilter.ts
Normal file
82
utils/SCsortFilter.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Sorrt Keywords by user's given input.
|
||||
* @param {SCKeywordType[]} theKeywords - The Keywords to sort.
|
||||
* @param {string} sortBy - The sort method.
|
||||
* @returns {SCKeywordType[]}
|
||||
*/
|
||||
export const SCsortKeywords = (theKeywords:SCKeywordType[], sortBy:string) : SCKeywordType[] => {
|
||||
let sortedItems = [];
|
||||
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) => (a.impressions > b.impressions ? 1 : -1));
|
||||
break;
|
||||
case 'imp_desc':
|
||||
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.impressions > a.impressions ? 1 : -1));
|
||||
break;
|
||||
case 'visits_asc':
|
||||
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.clicks > b.clicks ? 1 : -1));
|
||||
break;
|
||||
case 'visits_desc':
|
||||
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);
|
||||
break;
|
||||
case 'ctr_desc':
|
||||
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 = 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 = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position }));
|
||||
break;
|
||||
case 'alpha_desc':
|
||||
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (b.keyword > a.keyword ? 1 : -1));
|
||||
break;
|
||||
case 'alpha_asc':
|
||||
sortedItems = theKeywords.sort((a: SCKeywordType, b: SCKeywordType) => (a.keyword > b.keyword ? 1 : -1));
|
||||
break;
|
||||
default:
|
||||
return theKeywords;
|
||||
}
|
||||
|
||||
return sortedItems;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the Keywords by Device when the Device buttons are switched
|
||||
* @param {SCKeywordType[]} sortedKeywords - The Sorted Keywords.
|
||||
* @param {string} device - Device name (desktop or mobile).
|
||||
* @returns {{desktop: SCKeywordType[], mobile: SCKeywordType[] } }
|
||||
*/
|
||||
export const SCkeywordsByDevice = (sortedKeywords: SCKeywordType[], device: string): {[key: string]: SCKeywordType[] } => {
|
||||
const deviceKeywords: {[key:string] : SCKeywordType[]} = { desktop: [], mobile: [] };
|
||||
sortedKeywords.forEach((keyword) => {
|
||||
if (keyword.device === device) { deviceKeywords[device].push(keyword); }
|
||||
});
|
||||
return deviceKeywords;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters the keywords by country, search string or tags.
|
||||
* @param {SCKeywordType[]} keywords - The keywords.
|
||||
* @param {KeywordFilters} filterParams - The user Selected filter object.
|
||||
* @returns {SCKeywordType[]}
|
||||
*/
|
||||
export const SCfilterKeywords = (keywords: SCKeywordType[], filterParams: KeywordFilters):SCKeywordType[] => {
|
||||
const filteredItems:SCKeywordType[] = [];
|
||||
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);
|
||||
|
||||
if (countryMatch && searchMatch) {
|
||||
filteredItems.push(keywrd);
|
||||
}
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
};
|
||||
@@ -253,6 +253,262 @@ const countries: countryData = {
|
||||
ZA: ['South Africa', 'Pretoria', 'af'],
|
||||
ZM: ['Zambia', 'Lusaka', 'en'],
|
||||
ZW: ['Zimbabwe', 'Harare', 'en'],
|
||||
ZZ: ['Unknown', 'Unknown', 'en'],
|
||||
};
|
||||
|
||||
export const countryAlphaTwoCodes: countryCodeData = {
|
||||
AFG: 'AF',
|
||||
ALA: 'AX',
|
||||
ALB: 'AL',
|
||||
DZA: 'DZ',
|
||||
ASM: 'AS',
|
||||
AND: 'AD',
|
||||
AGO: 'AO',
|
||||
AIA: 'AI',
|
||||
ATA: 'AQ',
|
||||
ATG: 'AG',
|
||||
ARG: 'AR',
|
||||
ARM: 'AM',
|
||||
ABW: 'AW',
|
||||
AUS: 'AU',
|
||||
AUT: 'AT',
|
||||
AZE: 'AZ',
|
||||
BHS: 'BS',
|
||||
BHR: 'BH',
|
||||
BGD: 'BD',
|
||||
BRB: 'BB',
|
||||
BLR: 'BY',
|
||||
BEL: 'BE',
|
||||
BLZ: 'BZ',
|
||||
BEN: 'BJ',
|
||||
BMU: 'BM',
|
||||
BTN: 'BT',
|
||||
BOL: 'BO',
|
||||
BES: 'BQ',
|
||||
BIH: 'BA',
|
||||
BWA: 'BW',
|
||||
BVT: 'BV',
|
||||
BRA: 'BR',
|
||||
IOT: 'IO',
|
||||
BRN: 'BN',
|
||||
BGR: 'BG',
|
||||
BFA: 'BF',
|
||||
BDI: 'BI',
|
||||
CPV: 'CV',
|
||||
KHM: 'KH',
|
||||
CMR: 'CM',
|
||||
CAN: 'CA',
|
||||
CYM: 'KY',
|
||||
CAF: 'CF',
|
||||
TCD: 'TD',
|
||||
CHL: 'CL',
|
||||
CHN: 'CN',
|
||||
CXR: 'CX',
|
||||
CCK: 'CC',
|
||||
COL: 'CO',
|
||||
COM: 'KM',
|
||||
COG: 'CG',
|
||||
COD: 'CD',
|
||||
COK: 'CK',
|
||||
CRI: 'CR',
|
||||
CIV: 'CI',
|
||||
HRV: 'HR',
|
||||
CUB: 'CU',
|
||||
CUW: 'CW',
|
||||
CYP: 'CY',
|
||||
CZE: 'CZ',
|
||||
DNK: 'DK',
|
||||
DJI: 'DJ',
|
||||
DMA: 'DM',
|
||||
DOM: 'DO',
|
||||
ECU: 'EC',
|
||||
EGY: 'EG',
|
||||
SLV: 'SV',
|
||||
GNQ: 'GQ',
|
||||
ERI: 'ER',
|
||||
EST: 'EE',
|
||||
SWZ: 'SZ',
|
||||
ETH: 'ET',
|
||||
FLK: 'FK',
|
||||
FRO: 'FO',
|
||||
FJI: 'FJ',
|
||||
FIN: 'FI',
|
||||
FRA: 'FR',
|
||||
GUF: 'GF',
|
||||
PYF: 'PF',
|
||||
ATF: 'TF',
|
||||
GAB: 'GA',
|
||||
GMB: 'GM',
|
||||
GEO: 'GE',
|
||||
DEU: 'DE',
|
||||
GHA: 'GH',
|
||||
GIB: 'GI',
|
||||
GRC: 'GR',
|
||||
GRL: 'GL',
|
||||
GRD: 'GD',
|
||||
GLP: 'GP',
|
||||
GUM: 'GU',
|
||||
GTM: 'GT',
|
||||
GGY: 'GG',
|
||||
GIN: 'GN',
|
||||
GNB: 'GW',
|
||||
GUY: 'GY',
|
||||
HTI: 'HT',
|
||||
HMD: 'HM',
|
||||
VAT: 'VA',
|
||||
HND: 'HN',
|
||||
HKG: 'HK',
|
||||
HUN: 'HU',
|
||||
ISL: 'IS',
|
||||
IND: 'IN',
|
||||
IDN: 'ID',
|
||||
IRN: 'IR',
|
||||
IRQ: 'IQ',
|
||||
IRL: 'IE',
|
||||
IMN: 'IM',
|
||||
ISR: 'IL',
|
||||
ITA: 'IT',
|
||||
JAM: 'JM',
|
||||
JPN: 'JP',
|
||||
JEY: 'JE',
|
||||
JOR: 'JO',
|
||||
KAZ: 'KZ',
|
||||
KEN: 'KE',
|
||||
KIR: 'KI',
|
||||
PRK: 'KP',
|
||||
KOR: 'KR',
|
||||
KWT: 'KW',
|
||||
KGZ: 'KG',
|
||||
LAO: 'LA',
|
||||
LVA: 'LV',
|
||||
LBN: 'LB',
|
||||
LSO: 'LS',
|
||||
LBR: 'LR',
|
||||
LBY: 'LY',
|
||||
LIE: 'LI',
|
||||
LTU: 'LT',
|
||||
LUX: 'LU',
|
||||
MAC: 'MO',
|
||||
MDG: 'MG',
|
||||
MWI: 'MW',
|
||||
MYS: 'MY',
|
||||
MDV: 'MV',
|
||||
MLI: 'ML',
|
||||
MLT: 'MT',
|
||||
MHL: 'MH',
|
||||
MTQ: 'MQ',
|
||||
MRT: 'MR',
|
||||
MUS: 'MU',
|
||||
MYT: 'YT',
|
||||
MEX: 'MX',
|
||||
FSM: 'FM',
|
||||
MDA: 'MD',
|
||||
MCO: 'MC',
|
||||
MNG: 'MN',
|
||||
MNE: 'ME',
|
||||
MSR: 'MS',
|
||||
MAR: 'MA',
|
||||
MOZ: 'MZ',
|
||||
MMR: 'MM',
|
||||
NAM: 'NA',
|
||||
NRU: 'NR',
|
||||
NPL: 'NP',
|
||||
NLD: 'NL',
|
||||
NCL: 'NC',
|
||||
NZL: 'NZ',
|
||||
NIC: 'NI',
|
||||
NER: 'NE',
|
||||
NGA: 'NG',
|
||||
NIU: 'NU',
|
||||
NFK: 'NF',
|
||||
MKD: 'MK',
|
||||
MNP: 'MP',
|
||||
NOR: 'NO',
|
||||
OMN: 'OM',
|
||||
PAK: 'PK',
|
||||
PLW: 'PW',
|
||||
PSE: 'PS',
|
||||
PAN: 'PA',
|
||||
PNG: 'PG',
|
||||
PRY: 'PY',
|
||||
PER: 'PE',
|
||||
PHL: 'PH',
|
||||
PCN: 'PN',
|
||||
POL: 'PL',
|
||||
PRT: 'PT',
|
||||
PRI: 'PR',
|
||||
QAT: 'QA',
|
||||
REU: 'RE',
|
||||
ROU: 'RO',
|
||||
RUS: 'RU',
|
||||
RWA: 'RW',
|
||||
BLM: 'BL',
|
||||
SHN: 'SH',
|
||||
KNA: 'KN',
|
||||
LCA: 'LC',
|
||||
MAF: 'MF',
|
||||
SPM: 'PM',
|
||||
VCT: 'VC',
|
||||
WSM: 'WS',
|
||||
SMR: 'SM',
|
||||
STP: 'ST',
|
||||
SAU: 'SA',
|
||||
SEN: 'SN',
|
||||
SRB: 'RS',
|
||||
SYC: 'SC',
|
||||
SLE: 'SL',
|
||||
SGP: 'SG',
|
||||
SXM: 'SX',
|
||||
SVK: 'SK',
|
||||
SVN: 'SI',
|
||||
SLB: 'SB',
|
||||
SOM: 'SO',
|
||||
ZAF: 'ZA',
|
||||
SGS: 'GS',
|
||||
SSD: 'SS',
|
||||
ESP: 'ES',
|
||||
LKA: 'LK',
|
||||
SDN: 'SD',
|
||||
SUR: 'SR',
|
||||
SJM: 'SJ',
|
||||
SWE: 'SE',
|
||||
CHE: 'CH',
|
||||
SYR: 'SY',
|
||||
TWN: 'TW',
|
||||
TJK: 'TJ',
|
||||
TZA: 'TZ',
|
||||
THA: 'TH',
|
||||
TLS: 'TL',
|
||||
TGO: 'TG',
|
||||
TKL: 'TK',
|
||||
TON: 'TO',
|
||||
TTO: 'TT',
|
||||
TUN: 'TN',
|
||||
TUR: 'TR',
|
||||
TKM: 'TM',
|
||||
TCA: 'TC',
|
||||
TUV: 'TV',
|
||||
UGA: 'UG',
|
||||
UKR: 'UA',
|
||||
ARE: 'AE',
|
||||
GBR: 'GB',
|
||||
USA: 'US',
|
||||
UMI: 'UM',
|
||||
URY: 'UY',
|
||||
UZB: 'UZ',
|
||||
VUT: 'VU',
|
||||
VEN: 'VE',
|
||||
VNM: 'VN',
|
||||
VGB: 'VG',
|
||||
VIR: 'VI',
|
||||
WLF: 'WF',
|
||||
ESH: 'EH',
|
||||
YEM: 'YE',
|
||||
ZMB: 'ZM',
|
||||
ZWE: 'ZW',
|
||||
ZZZ: 'ZZ',
|
||||
};
|
||||
|
||||
export const getCountryCodeFromAlphaThree = (AlphaThreeCode:string): string => countryAlphaTwoCodes[AlphaThreeCode];
|
||||
|
||||
export default countries;
|
||||
|
||||
45
utils/domains.ts
Normal file
45
utils/domains.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Keyword from '../database/models/keyword';
|
||||
import parseKeywords from './parseKeywords';
|
||||
import { readLocalSCData } from './searchConsole';
|
||||
|
||||
const getdomainStats = async (domains:DomainType[]): Promise<DomainType[]> => {
|
||||
const finalDomains: DomainType[] = [];
|
||||
console.log('domains: ', domains.length);
|
||||
|
||||
for (const domain of domains) {
|
||||
const domainWithStat = domain;
|
||||
|
||||
// First Get ALl The Keywords for this Domain
|
||||
const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain: domain.domain } });
|
||||
const keywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true })));
|
||||
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();
|
||||
domainWithStat.avgPosition = Math.round(keywordPositions / keywords.length);
|
||||
|
||||
// Then Load the SC File and read the stats and calculate the Last 7 days stats
|
||||
const localSCData = await readLocalSCData(domain.domain);
|
||||
const days = 7;
|
||||
if (localSCData && localSCData.stats && localSCData.stats.length) {
|
||||
const lastSevenStats = localSCData.stats.slice(-days);
|
||||
const totalStats = lastSevenStats.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 });
|
||||
domainWithStat.scVisits = totalStats.clicks;
|
||||
domainWithStat.scImpressions = totalStats.impressions;
|
||||
domainWithStat.scPosition = Math.round(totalStats.position / days);
|
||||
}
|
||||
|
||||
finalDomains.push(domainWithStat);
|
||||
}
|
||||
|
||||
return finalDomains;
|
||||
};
|
||||
|
||||
export default getdomainStats;
|
||||
@@ -6,21 +6,36 @@ import countries from './countries';
|
||||
* @param {string} domain - The domain name.
|
||||
* @returns {void}
|
||||
*/
|
||||
const exportCSV = (keywords: KeywordType[], domain:string) => {
|
||||
const csvHeader = 'ID,Keyword,Position,URL,Country,Device,Updated,Added,Tags\r\n';
|
||||
const exportCSV = (keywords: KeywordType[] | SCKeywordType[], domain:string, scDataDuration = 'lastThreeDays') => {
|
||||
const isSCKeywords = !!(keywords && keywords[0] && keywords[0].uid);
|
||||
let csvHeader = 'ID,Keyword,Position,URL,Country,Device,Updated,Added,Tags\r\n';
|
||||
let csvBody = '';
|
||||
let fileName = `${domain}-keywords_serp.csv`;
|
||||
|
||||
keywords.forEach((keywordData) => {
|
||||
const { ID, keyword, position, url, country, device, lastUpdated, added, tags } = keywordData;
|
||||
// eslint-disable-next-line max-len
|
||||
csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`;
|
||||
});
|
||||
console.log(keywords[0]);
|
||||
console.log('isSCKeywords:', isSCKeywords);
|
||||
|
||||
if (isSCKeywords) {
|
||||
csvHeader = 'ID,Keyword,Position,Impressions,Clicks,CTR,Country,Device\r\n';
|
||||
fileName = `${domain}-search-console-${scDataDuration}.csv`;
|
||||
keywords.forEach((keywordData, index) => {
|
||||
const { keyword, position, country, device, clicks, impressions, ctr } = keywordData as SCKeywordType;
|
||||
// eslint-disable-next-line max-len
|
||||
csvBody += `${index}, ${keyword}, ${position === 0 ? '-' : position}, ${impressions}, ${clicks}, ${ctr}, ${countries[country][0]}, ${device}\r\n`;
|
||||
});
|
||||
} else {
|
||||
keywords.forEach((keywordData) => {
|
||||
const { ID, keyword, position, url, country, device, lastUpdated, added, tags } = keywordData as KeywordType;
|
||||
// eslint-disable-next-line max-len
|
||||
csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`;
|
||||
});
|
||||
}
|
||||
|
||||
const blob = new Blob([csvHeader + csvBody], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `${domain}-keywords_serp.csv`);
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getKeywordsInsight, getPagesInsight } from './insight';
|
||||
import { readLocalSCData } from './searchConsole';
|
||||
|
||||
const serpBearLogo = 'https://i.imgur.com/ikAdjQq.png';
|
||||
const mobileIcon = 'https://i.imgur.com/SqXD9rd.png';
|
||||
const desktopIcon = 'https://i.imgur.com/Dx3u0XD.png';
|
||||
const serpBearLogo = 'https://erevanto.sirv.com/Images/serpbear/ikAdjQq.png';
|
||||
const mobileIcon = 'https://erevanto.sirv.com/Images/serpbear/SqXD9rd.png';
|
||||
const desktopIcon = 'https://erevanto.sirv.com/Images/serpbear/Dx3u0XD.png';
|
||||
const googleIcon = 'https://erevanto.sirv.com/Images/serpbear/Sx3u0X9.png';
|
||||
|
||||
type SCStatsObject = {
|
||||
[key:string]: {
|
||||
html: string,
|
||||
label: string,
|
||||
clicks?: number,
|
||||
impressions?: number
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Geenrate Human readable Time string.
|
||||
* Generate Human readable Time string.
|
||||
* @param {number} date - Keywords to scrape
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -24,12 +36,12 @@ const timeSince = (date:number) : string => {
|
||||
if (interval >= 1) return `${interval} days ago`;
|
||||
|
||||
interval = Math.floor(seconds / 3600);
|
||||
if (interval >= 1) return `${interval} hours ago`;
|
||||
if (interval >= 1) return `${interval} hrs ago`;
|
||||
|
||||
interval = Math.floor(seconds / 60);
|
||||
if (interval > 1) return `${interval} minutes ago`;
|
||||
if (interval > 1) return `${interval} mins ago`;
|
||||
|
||||
return `${Math.floor(seconds)} seconds ago`;
|
||||
return `${Math.floor(seconds)} secs ago`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,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;
|
||||
};
|
||||
@@ -98,7 +113,113 @@ const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promis
|
||||
.replace('{{stat}}', stat)
|
||||
.replace('{{preheader}}', stat);
|
||||
|
||||
return updatedEmail;
|
||||
const isConsoleIntegrated = !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL);
|
||||
const htmlWithSCStats = isConsoleIntegrated ? await generateGoogeleConsoleStats(domainName) : '';
|
||||
const emailHTML = updatedEmail.replace('{{SCStatsTable}}', htmlWithSCStats);
|
||||
|
||||
// await writeFile('testemail.html', emailHTML, { encoding: 'utf-8' });
|
||||
|
||||
return emailHTML;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the Email HTML for Google Search Console Data.
|
||||
* @param {string} domainName - The Domain name for which to generate the HTML.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateGoogeleConsoleStats = async (domainName:string): Promise<string> => {
|
||||
if (!domainName) return '';
|
||||
|
||||
const localSCData = await readLocalSCData(domainName);
|
||||
if (!localSCData || !localSCData.stats || !localSCData.stats.length) {
|
||||
return ''; // IF No SC Data Found, Abot the process.
|
||||
}
|
||||
|
||||
const scData:SCStatsObject = {
|
||||
stats: { html: '', label: 'Performance for Last 7 Days', clicks: 0, impressions: 0 },
|
||||
keywords: { html: '', label: 'Top 5 Keywords' },
|
||||
pages: { html: '', label: 'Top 5 Pages' },
|
||||
};
|
||||
const SCStats = localSCData && localSCData.stats && Array.isArray(localSCData.stats) ? localSCData.stats.reverse().slice(0, 7) : [];
|
||||
const keywords = getKeywordsInsight(localSCData, 'clicks', 'sevenDays');
|
||||
const pages = getPagesInsight(localSCData, 'clicks', 'sevenDays');
|
||||
const genColumn = (item:SCInsightItem, firstColumKey:string):string => {
|
||||
return `<tr class="keyword">
|
||||
<td>${item[firstColumKey as keyof SCInsightItem]}</td>
|
||||
<td>${item.clicks}</td>
|
||||
<td>${item.impressions}</td>
|
||||
<td>${Math.round(item.position)}</td>
|
||||
</tr>`;
|
||||
};
|
||||
if (SCStats.length > 0) {
|
||||
scData.stats.html = SCStats.reduce((acc, item) => acc + genColumn(item, 'date'), '');
|
||||
}
|
||||
if (keywords.length > 0) {
|
||||
scData.keywords.html = keywords.slice(0, 5).reduce((acc, item) => acc + genColumn(item, 'keyword'), '');
|
||||
}
|
||||
if (pages.length > 0) {
|
||||
scData.pages.html = pages.slice(0, 5).reduce((acc, item) => acc + genColumn(item, 'page'), '');
|
||||
}
|
||||
scData.stats.clicks = SCStats.reduce((acc, item) => acc + item.clicks, 0);
|
||||
scData.stats.impressions = SCStats.reduce((acc, item) => acc + item.impressions, 0);
|
||||
|
||||
// Create Stats Start, End Date
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const endDate = new Date(SCStats[0].date);
|
||||
const startDate = new Date(SCStats[SCStats.length - 1].date);
|
||||
|
||||
// Add the SC header Title
|
||||
let htmlWithSCStats = `<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="console_table">
|
||||
<tr>
|
||||
<td style="font-weight:bold;">
|
||||
<img class="google_icon" src="${googleIcon}" alt="Google"> Google Search Console Stats</h3>
|
||||
</td>
|
||||
<td class="stat" align="right" style="font-size: 12px;">
|
||||
${startDate.getDate()} ${months[startDate.getMonth()]} - ${endDate.getDate()} ${months[endDate.getMonth()]}
|
||||
(Last 7 Days)
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Add the SC Data Tables
|
||||
Object.keys(scData).forEach((itemKey) => {
|
||||
const scItem = scData[itemKey as keyof SCStatsObject];
|
||||
const scItemFirstColName = itemKey === 'stats' ? 'Date' : `${itemKey[0].toUpperCase()}${itemKey.slice(1)}`;
|
||||
htmlWithSCStats += `<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="subhead">
|
||||
<tr>
|
||||
<td style="font-weight:bold;">${scItem.label}</h3></td>
|
||||
${scItem.clicks && scItem.impressions ? (
|
||||
`<td class="stat" align="right">
|
||||
<strong>${scItem.clicks}</strong> Clicks | <strong>${scItem.impressions}</strong> Views
|
||||
</td>`
|
||||
)
|
||||
: ''
|
||||
}
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" class="main" style="margin-bottom:20px">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="keyword_table keyword_table--sc">
|
||||
<tbody>
|
||||
<tr align="left">
|
||||
<th>${scItemFirstColName}</th>
|
||||
<th>Clicks</th>
|
||||
<th>Views</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
${scItem.html}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>`;
|
||||
});
|
||||
|
||||
return htmlWithSCStats;
|
||||
};
|
||||
|
||||
export default generateEmail;
|
||||
|
||||
142
utils/insight.ts
Normal file
142
utils/insight.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export const sortInsightItems = (items:SCInsightItem[], sortBy: string = 'clicks') => {
|
||||
const sortKey = sortBy as keyof SCInsightItem;
|
||||
let sortedItems = [];
|
||||
switch (sortKey) {
|
||||
case 'clicks':
|
||||
sortedItems = items.sort((a, b) => (b.clicks > a.clicks ? 1 : -1));
|
||||
break;
|
||||
case 'impressions':
|
||||
sortedItems = items.sort((a, b) => (b.impressions > a.impressions ? 1 : -1));
|
||||
break;
|
||||
case 'position':
|
||||
sortedItems = items.sort((a, b) => (b.position > a.position ? 1 : -1));
|
||||
break;
|
||||
default:
|
||||
sortedItems = items;
|
||||
break;
|
||||
}
|
||||
return sortedItems;
|
||||
};
|
||||
|
||||
export const getCountryInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
|
||||
const keywordsCounts: { [key:string]: string[] } = {};
|
||||
const countryItems: { [key:string]: SCInsightItem } = {};
|
||||
const dateKey = queryDate as keyof SCDomainDataType;
|
||||
const scData = SCData[dateKey] ? SCData[dateKey] as SearchAnalyticsItem[] : [];
|
||||
const allCountries: string[] = [...new Set(scData.map((item) => item.country))];
|
||||
|
||||
allCountries.forEach((countryKey:string) => {
|
||||
const itemData = { clicks: 0, impressions: 0, ctr: 0, position: 0 };
|
||||
scData.forEach((itm) => {
|
||||
if (itm.country === countryKey) {
|
||||
itemData.clicks += itm.clicks;
|
||||
itemData.impressions += itm.impressions;
|
||||
itemData.ctr += itm.ctr;
|
||||
itemData.position += itm.position;
|
||||
if (!keywordsCounts[itm.country]) {
|
||||
keywordsCounts[itm.country] = [];
|
||||
}
|
||||
if (keywordsCounts[itm.country] && !keywordsCounts[itm.country].includes(itm.keyword)) {
|
||||
keywordsCounts[itm.country].push(itm.keyword);
|
||||
}
|
||||
}
|
||||
});
|
||||
countryItems[countryKey] = itemData;
|
||||
});
|
||||
|
||||
const countryInsight: SCInsightItem[] = Object.keys(countryItems).map((countryCode:string) => {
|
||||
return {
|
||||
...countryItems[countryCode],
|
||||
position: Math.round(countryItems[countryCode].position / keywordsCounts[countryCode].length),
|
||||
ctr: countryItems[countryCode].ctr / keywordsCounts[countryCode].length,
|
||||
keywords: keywordsCounts[countryCode].length,
|
||||
country: countryCode,
|
||||
};
|
||||
});
|
||||
|
||||
return sortBy ? sortInsightItems(countryInsight, sortBy) : countryInsight;
|
||||
};
|
||||
|
||||
export const getKeywordsInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
|
||||
const keywordItems: { [key:string]: SCInsightItem } = {};
|
||||
const keywordCounts: { [key:string]: number } = {};
|
||||
const countriesCount: { [key:string]: string[] } = {};
|
||||
const dateKey = queryDate as keyof SCDomainDataType;
|
||||
const scData = SCData[dateKey] ? SCData[dateKey] as SearchAnalyticsItem[] : [];
|
||||
const allKeywords: string[] = [...new Set(scData.map((item) => item.keyword))];
|
||||
|
||||
allKeywords.forEach((keyword:string) => {
|
||||
const itemData = { clicks: 0, impressions: 0, ctr: 0, position: 0 };
|
||||
const keywordKey = keyword.replaceAll(' ', '_');
|
||||
scData.forEach((itm) => {
|
||||
if (itm.keyword === keyword) {
|
||||
itemData.clicks += itm.clicks;
|
||||
itemData.impressions += itm.impressions;
|
||||
itemData.ctr += itm.ctr;
|
||||
itemData.position += itm.position;
|
||||
if (!countriesCount[keywordKey]) {
|
||||
countriesCount[keywordKey] = [];
|
||||
}
|
||||
if (countriesCount[keywordKey] && !countriesCount[keywordKey].includes(itm.country)) {
|
||||
countriesCount[keywordKey].push(itm.keyword);
|
||||
}
|
||||
keywordCounts[keywordKey] = keywordCounts[keywordKey] ? keywordCounts[keywordKey] + 1 : 1;
|
||||
}
|
||||
});
|
||||
keywordItems[keywordKey] = itemData;
|
||||
});
|
||||
|
||||
const keywordInsight: SCInsightItem[] = Object.keys(keywordItems).map((keyword:string) => {
|
||||
return {
|
||||
...keywordItems[keyword],
|
||||
position: Math.round(keywordItems[keyword].position / keywordCounts[keyword]),
|
||||
ctr: keywordItems[keyword].ctr / keywordCounts[keyword],
|
||||
countries: countriesCount[keyword].length,
|
||||
keyword: keyword.replaceAll('_', ' '),
|
||||
};
|
||||
});
|
||||
|
||||
return sortBy ? sortInsightItems(keywordInsight, sortBy) : keywordInsight;
|
||||
};
|
||||
|
||||
export const getPagesInsight = (SCData:SCDomainDataType, sortBy:string = 'clicks', queryDate:string = 'thirtyDays') : SCInsightItem[] => {
|
||||
const pagesItems: { [key:string]: SCInsightItem } = {};
|
||||
const keywordCounts: { [key:string]: number } = {};
|
||||
const countriesCount: { [key:string]: string[] } = {};
|
||||
const dateKey = queryDate as keyof SCDomainDataType;
|
||||
const scData = SCData[dateKey] ? SCData[dateKey] as SearchAnalyticsItem[] : [];
|
||||
const allPages: string[] = [...new Set(scData.map((item) => item.page))];
|
||||
|
||||
allPages.forEach((page:string) => {
|
||||
const itemData = { clicks: 0, impressions: 0, ctr: 0, position: 0 };
|
||||
scData.forEach((itm) => {
|
||||
if (itm.page === page) {
|
||||
itemData.clicks += itm.clicks;
|
||||
itemData.impressions += itm.impressions;
|
||||
itemData.ctr += itm.ctr;
|
||||
itemData.position += itm.position;
|
||||
if (!countriesCount[page]) {
|
||||
countriesCount[page] = [];
|
||||
}
|
||||
if (countriesCount[page] && !countriesCount[page].includes(itm.country)) {
|
||||
countriesCount[page].push(itm.country);
|
||||
}
|
||||
keywordCounts[page] = keywordCounts[page] ? keywordCounts[page] + 1 : 1;
|
||||
}
|
||||
});
|
||||
pagesItems[page] = itemData;
|
||||
});
|
||||
|
||||
const pagesInsight: SCInsightItem[] = Object.keys(pagesItems).map((page:string) => {
|
||||
return {
|
||||
...pagesItems[page],
|
||||
position: Math.round(pagesItems[page].position / keywordCounts[page]),
|
||||
ctr: pagesItems[page].ctr / keywordCounts[page],
|
||||
countries: countriesCount[page].length,
|
||||
keywords: keywordCounts[page],
|
||||
page,
|
||||
};
|
||||
});
|
||||
|
||||
return sortBy ? sortInsightItems(pagesInsight, sortBy) : pagesInsight;
|
||||
};
|
||||
117
utils/refresh.ts
117
utils/refresh.ts
@@ -1,41 +1,126 @@
|
||||
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 Parallal or not
|
||||
* @param {KeywordType[]} keywords - Keywords to scrape
|
||||
* Determining whether the keywords should be scraped in Parallel or not
|
||||
* @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 (settings.scraper_type === 'scrapingant') {
|
||||
refreshedResults = await refreshParallal(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 Google Keyword Search Result in Prallal.
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrape Google Keyword Search Result in Parallel.
|
||||
* @param {KeywordType[]} keywords - Keywords to scrape
|
||||
* @param {SettingsType} settings - The App Settings that contain the Scraper settings
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const refreshParallal = async (keywords:KeywordType[], settings:SettingsType) : Promise<RefreshResult[]> => {
|
||||
const refreshParallel = async (keywords:KeywordType[], settings:SettingsType) : Promise<RefreshResult[]> => {
|
||||
const promises: Promise<RefreshResult>[] = keywords.map((keyword) => {
|
||||
return scrapeKeywordFromGoogle(keyword, settings);
|
||||
});
|
||||
@@ -49,4 +134,4 @@ const refreshParallal = async (keywords:KeywordType[], settings:SettingsType) :
|
||||
});
|
||||
};
|
||||
|
||||
export default refreshKeywords;
|
||||
export default refreshAndUpdateKeywords;
|
||||
|
||||
132
utils/scraper.ts
132
utils/scraper.ts
@@ -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,14 +12,14 @@ 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
|
||||
@@ -32,9 +31,9 @@ export type RefreshResult = false | {
|
||||
* @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 = {
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246',
|
||||
Accept: 'application/json; charset=utf8;',
|
||||
@@ -46,21 +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)}`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
@@ -97,29 +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)) {
|
||||
const extracted = extractScrapedResult(res.data || res.html || res.result, settings.scraper_type);
|
||||
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;
|
||||
@@ -128,36 +141,45 @@ 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);
|
||||
|
||||
const $ = cheerio.load(content);
|
||||
const hasNumberofResult = $('body').find('#search > div > div');
|
||||
const searchResult = hasNumberofResult.children();
|
||||
let lastPosition = 0;
|
||||
|
||||
if (scraper_type === 'proxy') {
|
||||
const mainContent = $('body').find('#main');
|
||||
const children = $(mainContent).find('h3');
|
||||
|
||||
for (let index = 1; 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=.*/, '') : '';
|
||||
extractedResult.push({ title, url: cleanedURL, position: index });
|
||||
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, position: lastPosition });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 1; i < searchResult.length; i += 1) {
|
||||
if (searchResult[i]) {
|
||||
const title = $(searchResult[i]).find('h3').html();
|
||||
const url = $(searchResult[i]).find('a').attr('href');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
extractedResult.push({ title, url, position: i });
|
||||
lastPosition += 1;
|
||||
extractedResult.push({ title, url, position: lastPosition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extractedResult;
|
||||
@@ -170,7 +192,7 @@ export const extractScrapedResult = (content:string, scraper_type:string): Searc
|
||||
* @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.', ''));
|
||||
@@ -185,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 '[]'; });
|
||||
@@ -205,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 '[]'; });
|
||||
};
|
||||
|
||||
153
utils/searchConsole.ts
Normal file
153
utils/searchConsole.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { auth, searchconsole_v1 } from '@googleapis/searchconsole';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { getCountryCodeFromAlphaThree } from './countries';
|
||||
|
||||
export type SCDomainFetchError = {
|
||||
error: boolean,
|
||||
errorMsg: string,
|
||||
}
|
||||
type fetchConsoleDataResponse = SearchAnalyticsItem[] | SearchAnalyticsStat[] | SCDomainFetchError;
|
||||
const fetchSearchConsoleData = async (domainName:string, days:number, type?:string): Promise<fetchConsoleDataResponse> => {
|
||||
if (!domainName) return { error: true, errorMsg: 'Domain Not Provided!' };
|
||||
try {
|
||||
const authClient = new auth.GoogleAuth({
|
||||
credentials: {
|
||||
private_key: process.env.SEARCH_CONSOLE_PRIVATE_KEY ? process.env.SEARCH_CONSOLE_PRIVATE_KEY.replaceAll('\\n', '\n') : '',
|
||||
client_email: process.env.SEARCH_CONSOLE_CLIENT_EMAIL ? process.env.SEARCH_CONSOLE_CLIENT_EMAIL : '',
|
||||
},
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
],
|
||||
});
|
||||
const startDateRaw = new Date(new Date().setDate(new Date().getDate() - days));
|
||||
const padDate = (num:number) => String(num).padStart(2, '0');
|
||||
const startDate = `${startDateRaw.getFullYear()}-${padDate(startDateRaw.getMonth() + 1)}-${padDate(startDateRaw.getDate())}`;
|
||||
const endDate = `${new Date().getFullYear()}-${padDate(new Date().getMonth() + 1)}-${padDate(new Date().getDate())}`;
|
||||
const client = new searchconsole_v1.Searchconsole({ auth: authClient });
|
||||
// Params: https://developers.google.com/webmaster-tools/v1/searchanalytics/query
|
||||
let requestBody:any = {
|
||||
startDate,
|
||||
endDate,
|
||||
type: 'web',
|
||||
rowLimit: 1000,
|
||||
dataState: 'all',
|
||||
dimensions: ['query', 'device', 'country', 'page'],
|
||||
};
|
||||
if (type === 'stat') {
|
||||
requestBody = {
|
||||
startDate,
|
||||
endDate,
|
||||
dataState: 'all',
|
||||
dimensions: ['date'],
|
||||
};
|
||||
}
|
||||
|
||||
const res = client.searchanalytics.query({ siteUrl: `sc-domain:${domainName}`, requestBody });
|
||||
const resData:any = (await res).data;
|
||||
let finalRows = resData.rows ? resData.rows.map((item:SearchAnalyticsRawItem) => parseSearchConsoleItem(item, domainName)) : [];
|
||||
|
||||
if (type === 'stat' && resData.rows && resData.rows.length > 0) {
|
||||
// console.log(resData.rows);
|
||||
finalRows = [];
|
||||
resData.rows.forEach((row:SearchAnalyticsRawItem) => {
|
||||
finalRows.push({
|
||||
date: row.keys[0],
|
||||
clicks: row.clicks,
|
||||
impressions: row.impressions,
|
||||
ctr: row.ctr * 100,
|
||||
position: row.position,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return finalRows;
|
||||
} catch (error:any) {
|
||||
const qType = type === 'stats' ? '(stats)' : `(${days}days)`;
|
||||
console.log(`[ERROR] Search Console API Error for ${domainName} ${qType} : `, error?.response?.status, error?.response?.statusText);
|
||||
return { error: true, errorMsg: `${error?.response?.status}: ${error?.response?.statusText}` };
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchDomainSCData = async (domain:string): Promise<SCDomainDataType> => {
|
||||
const days = [3, 7, 30];
|
||||
const scDomainData:SCDomainDataType = { threeDays: [], sevenDays: [], thirtyDays: [], lastFetched: '', lastFetchError: '', stats: [] };
|
||||
if (domain) {
|
||||
for (const day of days) {
|
||||
const items = await fetchSearchConsoleData(domain, day);
|
||||
scDomainData.lastFetched = new Date().toJSON();
|
||||
if (Array.isArray(items)) {
|
||||
if (day === 3) scDomainData.threeDays = items as SearchAnalyticsItem[];
|
||||
if (day === 7) scDomainData.sevenDays = items as SearchAnalyticsItem[];
|
||||
if (day === 30) scDomainData.thirtyDays = items as SearchAnalyticsItem[];
|
||||
} else if (items.error) {
|
||||
scDomainData.lastFetchError = items.errorMsg;
|
||||
}
|
||||
}
|
||||
const stats = await fetchSearchConsoleData(domain, 30, 'stat');
|
||||
if (stats && Array.isArray(stats) && stats.length > 0) {
|
||||
scDomainData.stats = stats as SearchAnalyticsStat[];
|
||||
}
|
||||
await updateLocalSCData(domain, scDomainData);
|
||||
}
|
||||
|
||||
return scDomainData;
|
||||
};
|
||||
|
||||
export const parseSearchConsoleItem = (SCItem: SearchAnalyticsRawItem, domainName: string): SearchAnalyticsItem => {
|
||||
const { clicks = 0, impressions = 0, ctr = 0, position = 0 } = SCItem;
|
||||
const keyword = SCItem.keys[0];
|
||||
const device = SCItem.keys[1] ? SCItem.keys[1].toLowerCase() : 'desktop';
|
||||
const country = SCItem.keys[2] ? (getCountryCodeFromAlphaThree(SCItem.keys[2].toUpperCase()) || SCItem.keys[2]) : 'ZZ';
|
||||
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: ctr * 100, position, page };
|
||||
};
|
||||
|
||||
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 };
|
||||
const visits :any = { yesterday: 0, threeDays: 0, sevenDays: 0, thirtyDays: 0, avgSevenDays: 0, avgThreeDays: 0, avgThirtyDays: 0 };
|
||||
const ctr:any = { yesterday: 0, threeDays: 0, sevenDays: 0, thirtyDays: 0, avgSevenDays: 0, avgThreeDays: 0, avgThirtyDays: 0 };
|
||||
const position:any = { yesterday: 0, threeDays: 0, sevenDays: 0, thirtyDays: 0, avgSevenDays: 0, avgThreeDays: 0, avgThirtyDays: 0 };
|
||||
|
||||
const threeDaysData = SCData.threeDays.find((item:SearchAnalyticsItem) => item.uid === kuid) || {};
|
||||
const SevenDaysData = SCData.sevenDays.find((item:SearchAnalyticsItem) => item.uid === kuid) || {};
|
||||
const ThirdyDaysData = SCData.thirtyDays.find((item:SearchAnalyticsItem) => item.uid === kuid) || {};
|
||||
const totalData:any = { threeDays: threeDaysData, sevenDays: SevenDaysData, thirtyDays: ThirdyDaysData };
|
||||
|
||||
Object.keys(totalData).forEach((dataKey) => {
|
||||
let avgDataKey = 'avgThreeDays'; let divideBy = 3;
|
||||
if (dataKey === 'sevenDays') { avgDataKey = 'avgSevenDays'; divideBy = 7; }
|
||||
if (dataKey === 'thirtyDays') { avgDataKey = 'avgThirtyDays'; divideBy = 30; }
|
||||
// Actual Data
|
||||
impressions[dataKey] = totalData[dataKey].impressions || 0;
|
||||
visits[dataKey] = totalData[dataKey].clicks || 0;
|
||||
ctr[dataKey] = Math.round((totalData[dataKey].ctr || 0) * 100) / 100;
|
||||
position[dataKey] = totalData[dataKey].position ? Math.round(totalData[dataKey].position) : 0;
|
||||
// Average Data
|
||||
impressions[avgDataKey] = Math.round(impressions[dataKey] / divideBy);
|
||||
ctr[avgDataKey] = Math.round((ctr[dataKey] / divideBy) * 100) / 100;
|
||||
visits[avgDataKey] = Math.round(visits[dataKey] / divideBy);
|
||||
position[avgDataKey] = Math.round(position[dataKey] / divideBy);
|
||||
});
|
||||
const finalSCData = { impressions, visits, ctr, position };
|
||||
|
||||
return { ...keyword, scData: finalSCData };
|
||||
};
|
||||
|
||||
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 '{}'; });
|
||||
const domainSCData = JSON.parse(currentQueueRaw);
|
||||
return domainSCData;
|
||||
};
|
||||
|
||||
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: '' };
|
||||
await writeFile(filePath, JSON.stringify(scDomainData || emptyData), { encoding: 'utf-8' }).catch((err) => { console.log(err); });
|
||||
return scDomainData || emptyData;
|
||||
};
|
||||
|
||||
export default fetchSearchConsoleData;
|
||||
@@ -4,8 +4,9 @@
|
||||
* @param {string} sortBy - The sort method.
|
||||
* @returns {KeywordType[]}
|
||||
*/
|
||||
export const sortKeywords = (theKeywords:KeywordType[], sortBy:string) : KeywordType[] => {
|
||||
let sortedItems = [];
|
||||
export const sortKeywords = (theKeywords:KeywordType[], sortBy:string, scDataType?: string) : KeywordType[] => {
|
||||
let sortedItems: KeywordType[] = [];
|
||||
const keywords = theKeywords.map((k) => ({ ...k, position: k.position === 0 ? 111 : k.position }));
|
||||
switch (sortBy) {
|
||||
case 'date_asc':
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(b.added).getTime() - new Date(a.added).getTime());
|
||||
@@ -14,10 +15,12 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string) : Keyword
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(a.added).getTime() - new Date(b.added).getTime());
|
||||
break;
|
||||
case 'pos_asc':
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.position > a.position ? 1 : -1));
|
||||
sortedItems = keywords.sort((a: KeywordType, b: KeywordType) => (b.position > a.position ? 1 : -1));
|
||||
sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position }));
|
||||
break;
|
||||
case 'pos_desc':
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.position > b.position ? 1 : -1));
|
||||
sortedItems = keywords.sort((a: KeywordType, b: KeywordType) => (a.position > b.position ? 1 : -1));
|
||||
sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position }));
|
||||
break;
|
||||
case 'alpha_asc':
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.keyword > a.keyword ? 1 : -1));
|
||||
@@ -25,6 +28,42 @@ export const sortKeywords = (theKeywords:KeywordType[], sortBy:string) : Keyword
|
||||
case 'alpha_desc':
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1));
|
||||
break;
|
||||
case 'imp_desc':
|
||||
if (scDataType) {
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
|
||||
const bImpressionData = b.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
|
||||
const aImpressionData = a.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
|
||||
return aImpressionData > bImpressionData ? 1 : -1;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'imp_asc':
|
||||
if (scDataType) {
|
||||
sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => {
|
||||
const bImpressionData = b.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
|
||||
const aImpressionData = a.scData?.impressions[scDataType as keyof KeywordSCDataChild] || 0;
|
||||
return bImpressionData > aImpressionData ? 1 : -1;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'visits_desc':
|
||||
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 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;
|
||||
default:
|
||||
return theKeywords;
|
||||
}
|
||||
@@ -59,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) {
|
||||
|
||||
35
utils/validators.ts
Normal file
35
utils/validators.ts
Normal 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;
|
||||
};
|
||||
@@ -13,7 +13,17 @@ const verifyUser = (req: NextApiRequest, res: NextApiResponse): string => {
|
||||
const cookies = new Cookies(req, res);
|
||||
const token = cookies && cookies.get('token');
|
||||
|
||||
const allowedApiRoutes = ['GET:/api/keyword', 'GET:/api/keywords', 'GET:/api/domains', 'POST:/api/refresh', 'POST:/api/cron', 'POST:/api/notify'];
|
||||
const allowedApiRoutes = [
|
||||
'GET:/api/keyword',
|
||||
'GET:/api/keywords',
|
||||
'GET:/api/domains',
|
||||
'POST:/api/refresh',
|
||||
'POST:/api/cron',
|
||||
'POST:/api/notify',
|
||||
'POST:/api/searchconsole',
|
||||
'GET:/api/searchconsole',
|
||||
'GET:/api/insight',
|
||||
];
|
||||
const verifiedAPI = req.headers.authorization ? req.headers.authorization.substring('Bearer '.length) === process.env.APIKEY : false;
|
||||
const accessingAllowedRoute = req.url && req.method && allowedApiRoutes.includes(`${req.method}:${req.url.replace(/\?(.*)/, '')}`);
|
||||
console.log(req.method, req.url);
|
||||
|
||||
Reference in New Issue
Block a user