├── public ├── icon.png ├── favicon.ico ├── fflags.png ├── flagSprite42.png ├── icon-512x512.png ├── manifest.json └── vercel.svg ├── postcss.config.js ├── next.config.js ├── .dockerignore ├── .env.example ├── __test__ ├── components │ ├── Icon.test.tsx │ ├── Topbar.test.tsx │ ├── Sidebar.test.tsx │ ├── Modal.test.tsx │ └── Keyword.test.tsx ├── hooks │ └── domains.tests.tsx ├── pages │ ├── index.test.tsx │ └── domain.test.tsx ├── utils.tsx └── data.ts ├── jest.setup.js ├── .gitignore ├── tailwind.config.js ├── .stylelintrc.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── logout.ts │ ├── login.ts │ ├── keyword.ts │ ├── cron.ts │ ├── notify.ts │ ├── settings.ts │ ├── domains.ts │ ├── refresh.ts │ └── keywords.ts ├── index.tsx ├── domain │ └── [slug] │ │ └── index.tsx └── login │ └── index.tsx ├── database ├── database.ts └── models │ ├── domain.ts │ └── keyword.ts ├── .eslintrc.json ├── utils ├── parseKeywords.ts ├── exportcsv.ts ├── verifyUser.ts ├── refresh.ts ├── sortFilter.ts ├── generateEmail.ts └── countries.ts ├── tsconfig.json ├── jest.config.js ├── Dockerfile ├── LICENSE ├── components ├── common │ ├── Chart.tsx │ ├── ChartSlim.tsx │ ├── Modal.tsx │ ├── Sidebar.tsx │ ├── generateChartData.ts │ ├── TopBar.tsx │ └── SelectField.tsx ├── domains │ ├── AddDomain.tsx │ ├── DomainHeader.tsx │ └── DomainSettings.tsx └── keywords │ ├── KeywordTagManager.tsx │ ├── AddKeywords.tsx │ ├── KeywordFilter.tsx │ ├── KeywordDetails.tsx │ ├── Keyword.tsx │ └── KeywordsTable.tsx ├── services ├── settings.ts ├── domains.tsx └── keywords.tsx ├── types.d.ts ├── package.json ├── README.md ├── CHANGELOG.md ├── styles └── globals.css └── cron.js /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushev-s/serpbear/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushev-s/serpbear/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/fflags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushev-s/serpbear/HEAD/public/fflags.png -------------------------------------------------------------------------------- /public/flagSprite42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushev-s/serpbear/HEAD/public/flagSprite42.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ushev-s/serpbear/HEAD/public/icon-512x512.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: false, 5 | output: 'standalone', 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | Dockerfile 3 | .dockerignore 4 | node_modules 5 | npm-debug.log 6 | README.md 7 | .next 8 | .git 9 | .vscode 10 | fly.toml 11 | TODO 12 | data/database.sqlite 13 | data/settings.json 14 | data/failed_queue.json -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | USER=admin 2 | PASSWORD=0123456789 3 | SECRET=4715aed3216f7b0a38e6b534a958362654e96d10fbc04700770d572af3dce43625dd 4 | APIKEY=5saedXklbslhnapihe2pihp3pih4fdnakhjwq5 5 | SESSION_DURATION=24 6 | NEXT_PUBLIC_APP_URL=http://localhost:3000 -------------------------------------------------------------------------------- /__test__/components/Icon.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import Icon from '../../components/common/Icon'; 3 | 4 | describe('Icon Component', () => { 5 | it('renders without crashing', async () => { 6 | render(); 7 | expect(document.querySelector('svg')).toBeInTheDocument(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /__test__/components/Topbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import TopBar from '../../components/common/TopBar'; 3 | 4 | describe('TopBar Component', () => { 5 | it('renders without crashing', async () => { 6 | render( console.log() } />); 7 | expect( 8 | await screen.findByText('SerpBear'), 9 | ).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /__test__/components/Sidebar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import Sidebar from '../../components/common/Sidebar'; 3 | 4 | describe('Sidebar Component', () => { 5 | it('renders without crashing', async () => { 6 | render( console.log() } />); 7 | expect( 8 | await screen.findByText('SerpBear'), 9 | ).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | import 'isomorphic-fetch'; 3 | import './styles/globals.css'; 4 | // Optional: configure or set up a testing framework before each test. 5 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 6 | 7 | // Used for __tests__/testing-library.js 8 | // Learn more: https://github.com/testing-library/jest-dom 9 | import '@testing-library/jest-dom/extend-expect'; 10 | 11 | global.ResizeObserver = require('resize-observer-polyfill'); 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SerpBear", 3 | "short_name": "SerpBear", 4 | "icons": [ 5 | { 6 | "src": "/icon.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | 11 | { 12 | "src": "/icon-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#1d4ed8", 18 | "background_color": "#FFFFFF", 19 | "start_url": "/", 20 | "display": "standalone", 21 | "orientation": "portrait" 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | /.swc/ 9 | 10 | # next.js 11 | /.next/ 12 | /out/ 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | *.pem 20 | .vscode 21 | fly.toml 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | 40 | #todo 41 | TODO 42 | 43 | #database 44 | data/ -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | purge: { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | }, 9 | content: [ 10 | './pages/**/*.{js,ts,jsx,tsx}', 11 | './components/**/*.{js,ts,jsx,tsx}', 12 | ], 13 | safelist: [ 14 | 'max-h-48', 15 | 'w-[150px]', 16 | 'w-[240px]', 17 | 'min-w-[270px]', 18 | 'min-w-[180px]', 19 | ], 20 | theme: { 21 | extend: {}, 22 | }, 23 | plugins: [], 24 | }; 25 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "indentation": 3, 5 | "declaration-block-single-line-max-declarations": 2, 6 | "selector-class-pattern":null, 7 | "at-rule-no-unknown": [ 8 | true, 9 | { 10 | "ignoreAtRules": [ 11 | "tailwind", 12 | "apply", 13 | "variants", 14 | "responsive", 15 | "screen" 16 | ] 17 | } 18 | ], 19 | "declaration-block-trailing-semicolon": null, 20 | "no-descending-specificity": null 21 | } 22 | } -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import React from 'react'; 3 | import type { AppProps } from 'next/app'; 4 | import { QueryClient, QueryClientProvider } from 'react-query'; 5 | import { ReactQueryDevtools } from 'react-query/devtools'; 6 | 7 | function MyApp({ Component, pageProps }: AppProps) { 8 | const [queryClient] = React.useState(() => new QueryClient()); 9 | return 10 | 11 | 12 | ; 13 | } 14 | 15 | export default MyApp; 16 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | class MyDocument extends Document { 4 | // eslint-disable-next-line class-methods-use-this 5 | render() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | } 21 | 22 | export default MyDocument; 23 | -------------------------------------------------------------------------------- /database/database.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize-typescript'; 2 | import sqlite3 from 'sqlite3'; 3 | import Domain from './models/domain'; 4 | import Keyword from './models/keyword'; 5 | 6 | const connection = new Sequelize({ 7 | dialect: 'sqlite', 8 | host: '0.0.0.0', 9 | username: process.env.USER, 10 | password: process.env.PASSWORD, 11 | database: 'sequelize', 12 | dialectModule: sqlite3, 13 | pool: { 14 | max: 5, 15 | min: 0, 16 | idle: 10000, 17 | }, 18 | logging: false, 19 | models: [Domain, Keyword], 20 | storage: './data/database.sqlite', 21 | }); 22 | 23 | export default connection; 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "airbnb-base"], 3 | "rules":{ 4 | "linebreak-style": 0, 5 | "indent":"off", 6 | "no-undef":"off", 7 | "no-console": "off", 8 | "camelcase":"off", 9 | "object-curly-newline":"off", 10 | "no-use-before-define": "off", 11 | "no-restricted-syntax": "off", 12 | "no-await-in-loop": "off", 13 | "arrow-body-style":"off", 14 | "max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}], 15 | "import/extensions": [ 16 | "error", 17 | "ignorePackages", 18 | { 19 | "": "never", 20 | "js": "never", 21 | "jsx": "never", 22 | "ts": "never", 23 | "tsx": "never" 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/parseKeywords.ts: -------------------------------------------------------------------------------- 1 | import Keyword from '../database/models/keyword'; 2 | 3 | /** 4 | * Parses the SQL Keyword Model object to frontend cosumable object. 5 | * @param {Keyword[]} allKeywords - Keywords to scrape 6 | * @returns {KeywordType[]} 7 | */ 8 | const parseKeywords = (allKeywords: Keyword[]) : KeywordType[] => { 9 | const parsedItems = allKeywords.map((keywrd:Keyword) => ({ 10 | ...keywrd, 11 | history: JSON.parse(keywrd.history), 12 | tags: JSON.parse(keywrd.tags), 13 | lastResult: JSON.parse(keywrd.lastResult), 14 | lastUpdateError: keywrd.lastUpdateError !== 'false' && keywrd.lastUpdateError.includes('{') ? JSON.parse(keywrd.lastUpdateError) : false, 15 | })); 16 | return parsedItems; 17 | }; 18 | 19 | export default parseKeywords; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "experimentalDecorators": true 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx", 27 | "types.d.ts" 28 | ], 29 | "exclude": [ 30 | "node_modules" 31 | ] 32 | } -------------------------------------------------------------------------------- /__test__/hooks/domains.tests.tsx: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@testing-library/react'; 2 | // import { useFetchDomains } from '../../services/domains'; 3 | // import { createWrapper } from '../utils'; 4 | 5 | jest.mock('next/router', () => ({ 6 | useRouter: () => ({ 7 | query: { slug: 'compressimage-io' }, 8 | push: (link:string) => { console.log('Pushed', link); }, 9 | }), 10 | })); 11 | 12 | describe('DomainHooks', () => { 13 | it('useFetchDomains should fetch the Domains', async () => { 14 | // const { result } = renderHook(() => useFetchDomains(), { wrapper: createWrapper() }); 15 | const result = { current: { isSuccess: false, data: '' } }; 16 | await waitFor(() => { 17 | console.log('result.current: ', result.current.data); 18 | return expect(result.current.isSuccess).toBe(true); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest'); 2 | require('dotenv').config({ path: './.env.local' }); 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const customJestConfig = { 12 | // Add more setup options before each test is run 13 | setupFilesAfterEnv: ['/jest.setup.js'], 14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 15 | moduleDirectories: ['node_modules', '/'], 16 | testEnvironment: 'jest-environment-jsdom', 17 | }; 18 | 19 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 20 | 21 | module.exports = createJestConfig(customJestConfig); 22 | -------------------------------------------------------------------------------- /pages/api/logout.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import Cookies from 'cookies'; 3 | import verifyUser from '../../utils/verifyUser'; 4 | 5 | type logoutResponse = { 6 | success?: boolean 7 | error?: string|null, 8 | } 9 | 10 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 11 | const authorized = verifyUser(req, res); 12 | if (authorized !== 'authorized') { 13 | return res.status(401).json({ error: authorized }); 14 | } 15 | if (req.method === 'POST') { 16 | return logout(req, res); 17 | } 18 | return res.status(401).json({ success: false, error: 'Invalid Method' }); 19 | } 20 | 21 | const logout = async (req: NextApiRequest, res: NextApiResponse) => { 22 | const cookies = new Cookies(req, res); 23 | cookies.set('token', null, { maxAge: new Date().getTime() }); 24 | return res.status(200).json({ success: true, error: null }); 25 | }; 26 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS deps 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | RUN npm install 7 | COPY . . 8 | 9 | 10 | FROM node:lts-alpine AS builder 11 | WORKDIR /app 12 | COPY --from=deps /app ./ 13 | RUN npm run build 14 | 15 | 16 | FROM node:lts-alpine AS runner 17 | WORKDIR /app 18 | ENV NODE_ENV production 19 | RUN addgroup --system --gid 1001 nodejs 20 | RUN adduser --system --uid 1001 nextjs 21 | RUN set -xe && mkdir -p /app/data && chown nextjs:nodejs /app/data 22 | COPY --from=builder --chown=nextjs:nodejs /app/public ./public 23 | # COPY --from=builder --chown=nextjs:nodejs /app/data ./data 24 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 25 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 26 | 27 | # setup the cron 28 | COPY --from=builder --chown=nextjs:nodejs /app/cron.js ./ 29 | COPY --from=builder --chown=nextjs:nodejs /app/email ./email 30 | RUN rm package.json 31 | RUN npm init -y 32 | RUN npm i cryptr dotenv node-cron 33 | RUN npm i -g concurrently 34 | 35 | USER nextjs 36 | 37 | EXPOSE 3000 38 | 39 | CMD ["concurrently","node server.js", "node cron.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Amruth Pillai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /utils/exportcsv.ts: -------------------------------------------------------------------------------- 1 | import countries from './countries'; 2 | 3 | /** 4 | * Generates CSV File form the given domain & keywords, and automatically downloads it. 5 | * @param {KeywordType[]} keywords - The keywords of the domain 6 | * @param {string} domain - The domain name. 7 | * @returns {void} 8 | */ 9 | const exportCSV = (keywords: KeywordType[], domain:string) => { 10 | const csvHeader = 'ID,Keyword,Position,URL,Country,Device,Updated,Added,Tags\r\n'; 11 | let csvBody = ''; 12 | 13 | keywords.forEach((keywordData) => { 14 | const { ID, keyword, position, url, country, device, lastUpdated, added, tags } = keywordData; 15 | // eslint-disable-next-line max-len 16 | csvBody += `${ID}, ${keyword}, ${position === 0 ? '-' : position}, ${url || '-'}, ${countries[country][0]}, ${device}, ${lastUpdated}, ${added}, ${tags.join(',')}\r\n`; 17 | }); 18 | 19 | const blob = new Blob([csvHeader + csvBody], { type: 'text/csv;charset=utf-8;' }); 20 | const url = URL.createObjectURL(blob); 21 | const link = document.createElement('a'); 22 | link.setAttribute('href', url); 23 | link.setAttribute('download', `${domain}-keywords_serp.csv`); 24 | link.style.visibility = 'hidden'; 25 | document.body.appendChild(link); 26 | link.click(); 27 | document.body.removeChild(link); 28 | }; 29 | 30 | export default exportCSV; 31 | -------------------------------------------------------------------------------- /components/common/Chart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js'; 3 | import { Line } from 'react-chartjs-2'; 4 | 5 | ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); 6 | 7 | type ChartProps ={ 8 | labels: string[], 9 | sreies: number[] 10 | } 11 | 12 | const Chart = ({ labels, sreies }:ChartProps) => { 13 | const options = { 14 | responsive: true, 15 | maintainAspectRatio: false, 16 | animation: false as const, 17 | scales: { 18 | y: { 19 | reverse: true, 20 | min: 1, 21 | max: 100, 22 | }, 23 | }, 24 | plugins: { 25 | legend: { 26 | display: false, 27 | }, 28 | }, 29 | }; 30 | 31 | return ; 46 | }; 47 | 48 | export default Chart; 49 | -------------------------------------------------------------------------------- /database/models/domain.ts: -------------------------------------------------------------------------------- 1 | import { Table, Model, Column, DataType, PrimaryKey, Unique } from 'sequelize-typescript'; 2 | 3 | @Table({ 4 | timestamps: false, 5 | tableName: 'domain', 6 | }) 7 | 8 | class Domain extends Model { 9 | @PrimaryKey 10 | @Column({ type: DataType.INTEGER, allowNull: false, primaryKey: true, autoIncrement: true }) 11 | ID!: number; 12 | 13 | @Unique 14 | @Column({ type: DataType.STRING, allowNull: false, defaultValue: true, unique: true }) 15 | domain!: string; 16 | 17 | @Unique 18 | @Column({ type: DataType.STRING, allowNull: false, defaultValue: true, unique: true }) 19 | slug!: string; 20 | 21 | @Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 }) 22 | keywordCount!: number; 23 | 24 | @Column({ type: DataType.STRING, allowNull: true }) 25 | lastUpdated!: string; 26 | 27 | @Column({ type: DataType.STRING, allowNull: true }) 28 | added!: string; 29 | 30 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) 31 | tags!: string; 32 | 33 | @Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: true }) 34 | notification!: boolean; 35 | 36 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'daily' }) 37 | notification_interval!: string; 38 | 39 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: '' }) 40 | notification_emails!: string; 41 | } 42 | 43 | export default Domain; 44 | -------------------------------------------------------------------------------- /__test__/pages/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import Home from '../../pages/index'; 4 | 5 | describe('Home Page', () => { 6 | const queryClient = new QueryClient(); 7 | it('Renders without crashing', async () => { 8 | // const dummyDomain = { 9 | // ID: 1, 10 | // domain: 'compressimage.io', 11 | // slug: 'compressimage-io', 12 | // keywordCount: 0, 13 | // lastUpdated: '2022-11-11T10:00:32.243', 14 | // added: '2022-11-11T10:00:32.244', 15 | // tags: [], 16 | // notification: true, 17 | // notification_interval: 'daily', 18 | // notification_emails: '', 19 | // }; 20 | render( 21 | 22 | 23 | , 24 | ); 25 | // console.log(prettyDOM(renderer.container.firstChild)); 26 | expect(await screen.findByRole('main')).toBeInTheDocument(); 27 | expect(screen.queryByText('Add Domain')).not.toBeInTheDocument(); 28 | }); 29 | it('Should Display the Add Domain Modal when there are no Domains.', async () => { 30 | render( 31 | 32 | 33 | , 34 | ); 35 | expect(await screen.findByText('Add Domain')).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__test__/components/Modal.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Modal from '../../components/common/Modal'; 4 | 5 | // jest.mock('React', () => ({ 6 | // ...jest.requireActual('React'), 7 | // useEffect: jest.fn(), 8 | // })); 9 | // const mockedUseEffect = useEffect as jest.Mock; 10 | 11 | // jest.mock('../../components/common/Icon', () => () =>
); 12 | 13 | describe('Modal Component', () => { 14 | it('Renders without crashing', async () => { 15 | render( console.log() }>
); 16 | // mockedUseEffect.mock.calls[0](); 17 | expect(document.querySelector('.modal')).toBeInTheDocument(); 18 | }); 19 | // it('Sets up the escapae key shortcut', async () => { 20 | // render( console.log() }>
); 21 | // expect(mockedUseEffect).toBeCalled(); 22 | // }); 23 | it('Displays the Given Content', async () => { 24 | render( console.log() }> 25 |
26 |

Hello Modal!!

27 |
28 |
); 29 | expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument(); 30 | }); 31 | it('Renders Modal Title', async () => { 32 | render( console.log() } title="Sample Modal Title">

Some Modal Content

); 33 | expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /__test__/utils.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { rest } from 'msw'; 3 | import * as React from 'react'; 4 | import { QueryClient, QueryClientProvider } from 'react-query'; 5 | 6 | export const handlers = [ 7 | rest.get( 8 | '*/react-query', 9 | (req, res, ctx) => { 10 | return res( 11 | ctx.status(200), 12 | ctx.json({ 13 | name: 'mocked-react-query', 14 | }), 15 | ); 16 | }, 17 | ), 18 | ]; 19 | 20 | const createTestQueryClient = () => new QueryClient({ 21 | defaultOptions: { 22 | queries: { 23 | retry: false, 24 | }, 25 | }, 26 | }); 27 | 28 | export function renderWithClient(ui: React.ReactElement) { 29 | const testQueryClient = createTestQueryClient(); 30 | const { rerender, ...result } = render( 31 | {ui}, 32 | ); 33 | return { 34 | ...result, 35 | rerender: (rerenderUi: React.ReactElement) => rerender( 36 | {rerenderUi}, 37 | ), 38 | }; 39 | } 40 | 41 | export function createWrapper() { 42 | const testQueryClient = createTestQueryClient(); 43 | // eslint-disable-next-line react/display-name 44 | return ({ children }: {children: React.ReactNode}) => ( 45 | {children} 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /pages/api/login.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import jwt from 'jsonwebtoken'; 3 | import Cookies from 'cookies'; 4 | 5 | type loginResponse = { 6 | success?: boolean 7 | error?: string|null, 8 | } 9 | 10 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 11 | if (req.method === 'POST') { 12 | return loginUser(req, res); 13 | } 14 | return res.status(401).json({ success: false, error: 'Invalid Method' }); 15 | } 16 | 17 | const loginUser = async (req: NextApiRequest, res: NextApiResponse) => { 18 | if (!req.body.username || !req.body.password) { 19 | return res.status(401).json({ error: 'Username Password Missing' }); 20 | } 21 | 22 | if (req.body.username === process.env.USER 23 | && req.body.password === process.env.PASSWORD && process.env.SECRET) { 24 | const token = jwt.sign({ user: process.env.USER }, process.env.SECRET); 25 | const cookies = new Cookies(req, res); 26 | const expireDate = new Date(); 27 | const sessDuration = process.env.SESSION_DURATION; 28 | expireDate.setHours((sessDuration && parseInt(sessDuration, 10)) || 24); 29 | cookies.set('token', token, { httpOnly: true, sameSite: 'lax', maxAge: expireDate.getTime() }); 30 | return res.status(200).json({ success: true, error: null }); 31 | } 32 | 33 | const error = req.body.username !== process.env.USER ? 'Incorrect Username' : 'Incorrect Password'; 34 | 35 | return res.status(401).json({ success: false, error }); 36 | }; 37 | -------------------------------------------------------------------------------- /services/settings.ts: -------------------------------------------------------------------------------- 1 | import toast from 'react-hot-toast'; 2 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 3 | 4 | export async function fetchSettings() { 5 | const res = await fetch(`${window.location.origin}/api/settings`, { method: 'GET' }); 6 | return res.json(); 7 | } 8 | 9 | export function useFetchSettings() { 10 | return useQuery('settings', () => fetchSettings()); 11 | } 12 | 13 | const useUpdateSettings = (onSuccess:Function|undefined) => { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation(async (settings: SettingsType) => { 17 | // console.log('settings: ', JSON.stringify(settings)); 18 | 19 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 20 | const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ settings }) }; 21 | const res = await fetch(`${window.location.origin}/api/settings`, fetchOpts); 22 | if (res.status >= 400 && res.status < 600) { 23 | throw new Error('Bad response from server'); 24 | } 25 | return res.json(); 26 | }, { 27 | onSuccess: async () => { 28 | if (onSuccess) { 29 | onSuccess(); 30 | } 31 | toast('Settings Updated!', { icon: '✔️' }); 32 | queryClient.invalidateQueries(['settings']); 33 | }, 34 | onError: () => { 35 | console.log('Error Updating App Settings!!!'); 36 | toast('Error Updating App Settings.', { icon: '⚠️' }); 37 | }, 38 | }); 39 | }; 40 | 41 | export default useUpdateSettings; 42 | -------------------------------------------------------------------------------- /pages/api/keyword.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import db from '../../database/database'; 3 | import Keyword from '../../database/models/keyword'; 4 | import parseKeywords from '../../utils/parseKeywords'; 5 | import verifyUser from '../../utils/verifyUser'; 6 | 7 | type KeywordGetResponse = { 8 | keyword?: KeywordType | null 9 | error?: string|null, 10 | } 11 | 12 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 13 | const authorized = verifyUser(req, res); 14 | if (authorized === 'authorized' && req.method === 'GET') { 15 | await db.sync(); 16 | return getKeyword(req, res); 17 | } 18 | return res.status(401).json({ error: authorized }); 19 | } 20 | 21 | const getKeyword = async (req: NextApiRequest, res: NextApiResponse) => { 22 | if (!req.query.id && typeof req.query.id !== 'string') { 23 | return res.status(400).json({ error: 'Keyword ID is Required!' }); 24 | } 25 | console.log('KEYWORD: ', req.query.id); 26 | 27 | try { 28 | const query = { ID: parseInt((req.query.id as string), 10) }; 29 | const foundKeyword:Keyword| null = await Keyword.findOne({ where: query }); 30 | const pareseKeyword = foundKeyword && parseKeywords([foundKeyword.get({ plain: true })]); 31 | const keywords = pareseKeyword && pareseKeyword[0] ? pareseKeyword[0] : null; 32 | return res.status(200).json({ keyword: keywords }); 33 | } catch (error) { 34 | console.log(error); 35 | return res.status(400).json({ error: 'Error Loading Keyword' }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | type Domain = { 3 | ID: number, 4 | domain: string, 5 | slug: string, 6 | tags?: string[], 7 | notification: boolean, 8 | notification_interval: string, 9 | notification_emails: string, 10 | lastUpdated: string, 11 | added: string, 12 | keywordCount: number 13 | } 14 | 15 | type KeywordHistory = { 16 | [date:string] : number 17 | } 18 | 19 | type KeywordType = { 20 | ID: number, 21 | keyword: string, 22 | device: string, 23 | country: string, 24 | domain: string, 25 | lastUpdated: string, 26 | added: string, 27 | position: number, 28 | sticky: boolean, 29 | history: KeywordHistory, 30 | lastResult: KeywordLastResult[], 31 | url: string, 32 | tags: string[], 33 | updating: boolean, 34 | lastUpdateError: {date: string, error: string, scraper: string} | false 35 | } 36 | 37 | type KeywordLastResult = { 38 | position: number, 39 | url: string, 40 | title: string 41 | } 42 | 43 | type KeywordFilters = { 44 | countries: string[], 45 | tags: string[], 46 | search: string, 47 | } 48 | 49 | type countryData = { 50 | [ISO:string] : string[] 51 | } 52 | 53 | type DomainSettings = { 54 | notification_interval: string, 55 | notification_emails: string, 56 | } 57 | 58 | type SettingsType = { 59 | scraper_type: string, 60 | scaping_api?: string, 61 | proxy?: string, 62 | notification_interval: string, 63 | notification_email: string, 64 | notification_email_from: string, 65 | smtp_server: string, 66 | smtp_port: string, 67 | smtp_username: string, 68 | smtp_password: string 69 | } 70 | -------------------------------------------------------------------------------- /pages/api/cron.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import db from '../../database/database'; 3 | import Keyword from '../../database/models/keyword'; 4 | import { getAppSettings } from './settings'; 5 | import verifyUser from '../../utils/verifyUser'; 6 | import { refreshAndUpdateKeywords } from './refresh'; 7 | 8 | type CRONRefreshRes = { 9 | started: boolean 10 | error?: string|null, 11 | } 12 | 13 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 14 | await db.sync(); 15 | const authorized = verifyUser(req, res); 16 | if (authorized !== 'authorized') { 17 | return res.status(401).json({ error: authorized }); 18 | } 19 | if (req.method === 'POST') { 20 | return cronRefreshkeywords(req, res); 21 | } 22 | return res.status(502).json({ error: 'Unrecognized Route.' }); 23 | } 24 | 25 | const cronRefreshkeywords = async (req: NextApiRequest, res: NextApiResponse) => { 26 | try { 27 | const settings = await getAppSettings(); 28 | if (!settings || (settings && settings.scraper_type === 'never')) { 29 | return res.status(400).json({ started: false, error: 'Scraper has not been set up yet.' }); 30 | } 31 | await Keyword.update({ updating: true }, { where: {} }); 32 | const keywordQueries: Keyword[] = await Keyword.findAll(); 33 | 34 | refreshAndUpdateKeywords(keywordQueries, settings); 35 | 36 | return res.status(200).json({ started: true }); 37 | } catch (error) { 38 | console.log('ERROR cronRefreshkeywords: ', error); 39 | return res.status(400).json({ started: false, error: 'CRON Error refreshing keywords!' }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /components/common/ChartSlim.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler } from 'chart.js'; 3 | import { Line } from 'react-chartjs-2'; 4 | 5 | ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Title, Tooltip, Legend); 6 | 7 | type ChartProps ={ 8 | labels: string[], 9 | sreies: number[] 10 | } 11 | 12 | const ChartSlim = ({ labels, sreies }:ChartProps) => { 13 | const options = { 14 | responsive: true, 15 | maintainAspectRatio: false, 16 | animation: false as const, 17 | scales: { 18 | y: { 19 | display: false, 20 | reverse: true, 21 | min: 1, 22 | max: 100, 23 | }, 24 | x: { 25 | display: false, 26 | }, 27 | }, 28 | plugins: { 29 | tooltip: { 30 | enabled: false, 31 | }, 32 | legend: { 33 | display: false, 34 | }, 35 | }, 36 | }; 37 | 38 | return
39 | 56 |
; 57 | }; 58 | 59 | export default ChartSlim; 60 | -------------------------------------------------------------------------------- /__test__/data.ts: -------------------------------------------------------------------------------- 1 | export const dummyDomain = { 2 | ID: 1, 3 | domain: 'compressimage.io', 4 | slug: 'compressimage-io', 5 | keywordCount: 0, 6 | lastUpdated: '2022-11-11T10:00:32.243', 7 | added: '2022-11-11T10:00:32.244', 8 | tags: [], 9 | notification: true, 10 | notification_interval: 'daily', 11 | notification_emails: '', 12 | }; 13 | 14 | export const dummyKeywords = [ 15 | { 16 | ID: 1, 17 | keyword: 'compress image', 18 | device: 'desktop', 19 | country: 'US', 20 | domain: 'compressimage.io', 21 | lastUpdated: '2022-11-15T10:49:53.113', 22 | added: '2022-11-11T10:01:06.951', 23 | position: 19, 24 | history: { 25 | '2022-11-11': 21, 26 | '2022-11-12': 24, 27 | '2022-11-13': 24, 28 | '2022-11-14': 20, 29 | '2022-11-15': 19, 30 | }, 31 | url: 'https://compressimage.io/', 32 | tags: [], 33 | lastResult: [], 34 | sticky: false, 35 | updating: false, 36 | lastUpdateError: 'false', 37 | }, 38 | { 39 | ID: 2, 40 | keyword: 'image compressor', 41 | device: 'desktop', 42 | country: 'US', 43 | domain: 'compressimage.io', 44 | lastUpdated: '2022-11-15T10:49:53.119', 45 | added: '2022-11-15T10:01:06.951', 46 | position: 29, 47 | history: { 48 | '2022-11-11': 33, 49 | '2022-11-12': 34, 50 | '2022-11-13': 17, 51 | '2022-11-14': 30, 52 | '2022-11-15': 29, 53 | }, 54 | url: 'https://compressimage.io/', 55 | tags: ['compressor'], 56 | lastResult: [], 57 | sticky: false, 58 | updating: false, 59 | lastUpdateError: 'false', 60 | }, 61 | ]; 62 | -------------------------------------------------------------------------------- /components/common/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import Icon from './Icon'; 3 | 4 | type ModalProps = { 5 | children: React.ReactNode, 6 | width?: string, 7 | title?: string, 8 | closeModal: Function, 9 | } 10 | 11 | const Modal = ({ children, width = '1/2', closeModal, title }:ModalProps) => { 12 | useEffect(() => { 13 | const closeModalonEsc = (event:KeyboardEvent) => { 14 | if (event.key === 'Escape') { 15 | closeModal(); 16 | } 17 | }; 18 | window.addEventListener('keydown', closeModalonEsc, false); 19 | return () => { 20 | window.removeEventListener('keydown', closeModalonEsc, false); 21 | }; 22 | }, [closeModal]); 23 | 24 | const closeOnBGClick = (e:React.SyntheticEvent) => { 25 | e.stopPropagation(); 26 | e.nativeEvent.stopImmediatePropagation(); 27 | if (e.target === e.currentTarget) { closeModal(); } 28 | }; 29 | 30 | return ( 31 |
32 |
35 | {title &&

{title}

} 36 | 41 |
{children}
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default Modal; 48 | -------------------------------------------------------------------------------- /utils/verifyUser.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import Cookies from 'cookies'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | /** 6 | * Psuedo Middleware: Verifies the user by their cookie value or their API Key 7 | * When accessing with API key only certain routes are accessible. 8 | * @param {NextApiRequest} req - The Next Request 9 | * @param {NextApiResponse} res - The Next Response. 10 | * @returns {string} 11 | */ 12 | const verifyUser = (req: NextApiRequest, res: NextApiResponse): string => { 13 | const cookies = new Cookies(req, res); 14 | const token = cookies && cookies.get('token'); 15 | 16 | const allowedApiRoutes = ['GET:/api/keyword', 'GET:/api/keywords', 'GET:/api/domains', 'POST:/api/refresh', 'POST:/api/cron', 'POST:/api/notify']; 17 | const verifiedAPI = req.headers.authorization ? req.headers.authorization.substring('Bearer '.length) === process.env.APIKEY : false; 18 | const accessingAllowedRoute = req.url && req.method && allowedApiRoutes.includes(`${req.method}:${req.url.replace(/\?(.*)/, '')}`); 19 | console.log(req.method, req.url); 20 | 21 | let authorized: string = ''; 22 | if (token && process.env.SECRET) { 23 | jwt.verify(token, process.env.SECRET, (err) => { 24 | // console.log(err); 25 | authorized = err ? 'Not authorized' : 'authorized'; 26 | }); 27 | } else if (verifiedAPI && accessingAllowedRoute) { 28 | authorized = 'authorized'; 29 | } else { 30 | if (!token) { 31 | authorized = 'Not authorized'; 32 | } 33 | if (token && !process.env.SECRET) { 34 | authorized = 'Token has not been Setup.'; 35 | } 36 | if (verifiedAPI && !accessingAllowedRoute) { 37 | authorized = 'This Route cannot be accessed with API.'; 38 | } 39 | if (req.headers.authorization && !verifiedAPI) { 40 | authorized = 'Invalid API Key Provided.'; 41 | } 42 | } 43 | 44 | return authorized; 45 | }; 46 | 47 | export default verifyUser; 48 | -------------------------------------------------------------------------------- /utils/refresh.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | import { RefreshResult, scrapeKeywordFromGoogle } from './scraper'; 3 | 4 | /** 5 | * Refreshes the Keywords position by Scraping Google Search Result by 6 | * Determining whether the keywords should be scraped in Parallal or not 7 | * @param {KeywordType[]} keywords - Keywords to scrape 8 | * @param {SettingsType} settings - The App Settings that contain the Scraper settings 9 | * @returns {Promise} 10 | */ 11 | const refreshKeywords = async (keywords:KeywordType[], settings:SettingsType): Promise => { 12 | if (!keywords || keywords.length === 0) { return []; } 13 | const start = performance.now(); 14 | 15 | let refreshedResults: RefreshResult[] = []; 16 | 17 | if (settings.scraper_type === 'scrapingant') { 18 | refreshedResults = await refreshParallal(keywords, settings); 19 | } else { 20 | for (const keyword of keywords) { 21 | console.log('START SCRAPE: ', keyword.keyword); 22 | const refreshedkeywordData = await scrapeKeywordFromGoogle(keyword, settings); 23 | refreshedResults.push(refreshedkeywordData); 24 | } 25 | } 26 | 27 | const end = performance.now(); 28 | console.log(`time taken: ${end - start}ms`); 29 | return refreshedResults; 30 | }; 31 | 32 | /** 33 | * Scrape Google Keyword Search Result in Prallal. 34 | * @param {KeywordType[]} keywords - Keywords to scrape 35 | * @param {SettingsType} settings - The App Settings that contain the Scraper settings 36 | * @returns {Promise} 37 | */ 38 | const refreshParallal = async (keywords:KeywordType[], settings:SettingsType) : Promise => { 39 | const promises: Promise[] = keywords.map((keyword) => { 40 | return scrapeKeywordFromGoogle(keyword, settings); 41 | }); 42 | 43 | return Promise.all(promises).then((promiseData) => { 44 | console.log('ALL DONE!!!'); 45 | return promiseData; 46 | }).catch((err) => { 47 | console.log(err); 48 | return []; 49 | }); 50 | }; 51 | 52 | export default refreshKeywords; 53 | -------------------------------------------------------------------------------- /database/models/keyword.ts: -------------------------------------------------------------------------------- 1 | import { Table, Model, Column, DataType, PrimaryKey } from 'sequelize-typescript'; 2 | 3 | @Table({ 4 | timestamps: false, 5 | tableName: 'keyword', 6 | }) 7 | 8 | class Keyword extends Model { 9 | @PrimaryKey 10 | @Column({ type: DataType.INTEGER, allowNull: false, primaryKey: true, autoIncrement: true }) 11 | ID!: number; 12 | 13 | @Column({ type: DataType.STRING, allowNull: false }) 14 | keyword!: string; 15 | 16 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'desktop' }) 17 | device!: string; 18 | 19 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'US' }) 20 | country!: string; 21 | 22 | @Column({ type: DataType.STRING, allowNull: false }) 23 | domain!: string; 24 | 25 | // @ForeignKey(() => Domain) 26 | // @Column({ allowNull: false }) 27 | // domainID!: number; 28 | 29 | // @BelongsTo(() => Domain) 30 | // domain!: Domain; 31 | 32 | @Column({ type: DataType.STRING, allowNull: true }) 33 | lastUpdated!: string; 34 | 35 | @Column({ type: DataType.STRING, allowNull: true }) 36 | added!: string; 37 | 38 | @Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0 }) 39 | position!: number; 40 | 41 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) 42 | history!: string; 43 | 44 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) 45 | url!: string; 46 | 47 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) 48 | tags!: string; 49 | 50 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: JSON.stringify([]) }) 51 | lastResult!: string; 52 | 53 | @Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: true }) 54 | sticky!: boolean; 55 | 56 | @Column({ type: DataType.BOOLEAN, allowNull: true, defaultValue: false }) 57 | updating!: boolean; 58 | 59 | @Column({ type: DataType.STRING, allowNull: true, defaultValue: 'false' }) 60 | lastUpdateError!: string; 61 | } 62 | 63 | export default Keyword; 64 | -------------------------------------------------------------------------------- /components/common/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import { useRouter } from 'next/router'; 4 | import Icon from './Icon'; 5 | 6 | type SidebarProps = { 7 | domains: Domain[], 8 | showAddModal: Function 9 | } 10 | 11 | const Sidebar = ({ domains, showAddModal } : SidebarProps) => { 12 | const router = useRouter(); 13 | 14 | return ( 15 |
16 |

17 | SerpBear 18 |

19 |
20 | 38 |
39 |
40 | 41 |
42 |
43 | ); 44 | }; 45 | 46 | export default Sidebar; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serpbear", 3 | "version": "0.1.6", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "cron": "node cron.js", 10 | "start:all": "concurrently npm:start npm:cron", 11 | "lint": "next lint", 12 | "test": "jest --watch --verbose", 13 | "test:ci": "jest --ci", 14 | "test:cv": "jest --coverage --coverageDirectory='coverage'", 15 | "release": "standard-version" 16 | }, 17 | "dependencies": { 18 | "@testing-library/react": "^13.4.0", 19 | "@types/react-transition-group": "^4.4.5", 20 | "axios": "^1.1.3", 21 | "axios-retry": "^3.3.1", 22 | "chart.js": "^3.9.1", 23 | "cheerio": "^1.0.0-rc.12", 24 | "concurrently": "^7.6.0", 25 | "cookies": "^0.8.0", 26 | "cryptr": "^6.0.3", 27 | "dayjs": "^1.11.5", 28 | "dotenv": "^16.0.3", 29 | "https-proxy-agent": "^5.0.1", 30 | "isomorphic-fetch": "^3.0.0", 31 | "jsonwebtoken": "^8.5.1", 32 | "msw": "^0.49.0", 33 | "next": "12.3.1", 34 | "node-cron": "^3.0.2", 35 | "nodemailer": "^6.8.0", 36 | "react": "18.2.0", 37 | "react-chartjs-2": "^4.3.1", 38 | "react-dom": "18.2.0", 39 | "react-hot-toast": "^2.4.0", 40 | "react-query": "^3.39.2", 41 | "react-timeago": "^7.1.0", 42 | "react-transition-group": "^4.4.5", 43 | "reflect-metadata": "^0.1.13", 44 | "sequelize": "^6.25.2", 45 | "sequelize-typescript": "^2.1.5", 46 | "sqlite3": "^5.1.2" 47 | }, 48 | "devDependencies": { 49 | "@testing-library/jest-dom": "^5.16.5", 50 | "@types/cookies": "^0.7.7", 51 | "@types/cryptr": "^4.0.1", 52 | "@types/isomorphic-fetch": "^0.0.36", 53 | "@types/jsonwebtoken": "^8.5.9", 54 | "@types/node": "18.11.0", 55 | "@types/nodemailer": "^6.4.6", 56 | "@types/react": "18.0.21", 57 | "@types/react-dom": "18.0.6", 58 | "@types/react-timeago": "^4.1.3", 59 | "autoprefixer": "^10.4.12", 60 | "eslint": "8.25.0", 61 | "eslint-config-airbnb-base": "^15.0.0", 62 | "eslint-config-next": "12.3.1", 63 | "jest": "^29.3.1", 64 | "jest-environment-jsdom": "^29.3.1", 65 | "postcss": "^8.4.18", 66 | "prettier": "^2.7.1", 67 | "resize-observer-polyfill": "^1.5.1", 68 | "sass": "^1.55.0", 69 | "standard-version": "^9.5.0", 70 | "stylelint-config-standard": "^29.0.0", 71 | "tailwindcss": "^3.1.8", 72 | "typescript": "4.8.4" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /__test__/components/Keyword.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import Keyword from '../../components/keywords/Keyword'; 3 | import { dummyKeywords } from '../data'; 4 | 5 | const keywordFunctions = { 6 | refreshkeyword: jest.fn(), 7 | favoriteKeyword: jest.fn(), 8 | removeKeyword: jest.fn(), 9 | selectKeyword: jest.fn(), 10 | manageTags: jest.fn(), 11 | showKeywordDetails: jest.fn(), 12 | }; 13 | 14 | describe('Keyword Component', () => { 15 | it('renders without crashing', async () => { 16 | render(); 17 | expect(await screen.findByText('compress image')).toBeInTheDocument(); 18 | }); 19 | it('Should Render Position Correctly', async () => { 20 | render(); 21 | const positionElement = document.querySelector('.keyword_position'); 22 | expect(positionElement?.childNodes[0].nodeValue).toBe('19'); 23 | }); 24 | it('Should Display Position Change arrow', async () => { 25 | render(); 26 | const positionElement = document.querySelector('.keyword_position i'); 27 | expect(positionElement?.textContent).toBe('▲ 1'); 28 | }); 29 | it('Should Display the SERP Page URL', async () => { 30 | render(); 31 | const positionElement = document.querySelector('.keyword_url'); 32 | expect(positionElement?.textContent).toBe('/'); 33 | }); 34 | it('Should Display the Keyword Options on dots Click', async () => { 35 | render(); 36 | const button = document.querySelector('.keyword .keyword_dots'); 37 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 38 | expect(document.querySelector('.keyword_options')).toBeVisible(); 39 | }); 40 | // it('Should favorite Keywords', async () => { 41 | // render(); 42 | // const button = document.querySelector('.keyword .keyword_dots'); 43 | // if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 44 | // const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a'); 45 | // if (option) fireEvent(option, new MouseEvent('click', { bubbles: true })); 46 | // const { favoriteKeyword } = keywordFunctions; 47 | // expect(favoriteKeyword).toHaveBeenCalled(); 48 | // }); 49 | }); 50 | -------------------------------------------------------------------------------- /components/domains/AddDomain.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Modal from '../common/Modal'; 3 | import { useAddDomain } from '../../services/domains'; 4 | 5 | type AddDomainProps = { 6 | closeModal: Function 7 | } 8 | 9 | const AddDomain = ({ closeModal }: AddDomainProps) => { 10 | const [newDomain, setNewDomain] = useState(''); 11 | const [newDomainError, setNewDomainError] = useState(false); 12 | const { mutate: addMutate, isLoading: isAdding } = useAddDomain(() => closeModal()); 13 | 14 | const addDomain = () => { 15 | // console.log('ADD NEW DOMAIN', newDomain); 16 | if (/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/.test(newDomain.trim())) { 17 | setNewDomainError(false); 18 | // TODO: Domain Action 19 | addMutate(newDomain.trim()); 20 | } else { 21 | setNewDomainError(true); 22 | } 23 | }; 24 | 25 | const handleDomainInput = (e:React.FormEvent) => { 26 | if (e.currentTarget.value === '' && newDomainError) { setNewDomainError(false); } 27 | setNewDomain(e.currentTarget.value); 28 | }; 29 | 30 | return ( 31 | { closeModal(false); }} title={'Add New Domain'}> 32 |
33 |

34 | Domain Name {newDomainError && Not a Valid Domain} 35 |

36 | { 45 | if (e.code === 'Enter') { 46 | e.preventDefault(); 47 | addDomain(); 48 | } 49 | }} 50 | /> 51 |
52 | 53 | 56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | export default AddDomain; 63 | -------------------------------------------------------------------------------- /components/common/generateChartData.ts: -------------------------------------------------------------------------------- 1 | type ChartData = { 2 | labels: string[], 3 | sreies: number[] 4 | } 5 | 6 | export const generateChartData = (history: KeywordHistory): ChartData => { 7 | const currentDate = new Date(); 8 | const priorDates = []; 9 | const seriesDates: any = {}; 10 | let lastFoundSerp = 0; 11 | 12 | // First Generate Labels. The labels should be the last 30 days dates. Format: Oct 26 13 | for (let index = 30; index >= 0; index -= 1) { 14 | const pastDate = new Date(new Date().setDate(currentDate.getDate() - index)); 15 | priorDates.push(`${pastDate.getDate()}/${pastDate.getMonth() + 1}`); 16 | 17 | // Then Generate Series. if past date's serp does not exist, use 0. 18 | // If have a missing serp in between dates, use the previous date's serp to fill the gap. 19 | const pastDateKey = `${pastDate.getFullYear()}-${pastDate.getMonth() + 1}-${pastDate.getDate()}`; 20 | const serpOftheDate = history[pastDateKey]; 21 | const lastLargestSerp = lastFoundSerp > 0 ? lastFoundSerp : 0; 22 | seriesDates[pastDateKey] = history[pastDateKey] ? history[pastDateKey] : lastLargestSerp; 23 | if (lastFoundSerp < serpOftheDate) { lastFoundSerp = serpOftheDate; } 24 | } 25 | 26 | return { labels: priorDates, sreies: Object.values(seriesDates) }; 27 | }; 28 | 29 | export const generateTheChartData = (history: KeywordHistory, time:string = '30'): ChartData => { 30 | const currentDate = new Date(); let lastFoundSerp = 0; 31 | const chartData: ChartData = { labels: [], sreies: [] }; 32 | 33 | if (time === 'all') { 34 | Object.keys(history).forEach((dateKey) => { 35 | const serpVal = history[dateKey] ? history[dateKey] : 111; 36 | chartData.labels.push(dateKey); 37 | chartData.sreies.push(serpVal); 38 | }); 39 | } else { 40 | // First Generate Labels. The labels should be the last 30 days dates. Format: Oct 26 41 | for (let index = parseInt(time, 10); index >= 0; index -= 1) { 42 | const pastDate = new Date(new Date().setDate(currentDate.getDate() - index)); 43 | // Then Generate Series. if past date's serp does not exist, use 0. 44 | // If have a missing serp in between dates, use the previous date's serp to fill the gap. 45 | const pastDateKey = `${pastDate.getFullYear()}-${pastDate.getMonth() + 1}-${pastDate.getDate()}`; 46 | const prevSerp = history[pastDateKey]; 47 | const serpVal = prevSerp || (lastFoundSerp > 0 ? lastFoundSerp : 111); 48 | if (serpVal !== 0) { lastFoundSerp = prevSerp; } 49 | chartData.labels.push(pastDateKey); 50 | chartData.sreies.push(serpVal); 51 | } 52 | } 53 | // console.log(chartData); 54 | 55 | return chartData; 56 | }; 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![SerpBear](https://i.imgur.com/0S2zIH3.png) 2 | # SerpBear 3 | 4 | ![Codacy Badge](https://app.codacy.com/project/badge/Grade/7e7a0030c3f84c6fb56a3ce6273fbc1d) ![GitHub](https://img.shields.io/github/license/towfiqi/serpbear) ![GitHub package.json version](https://img.shields.io/github/package-json/v/towfiqi/serpbear) ![Docker Pulls](https://img.shields.io/docker/pulls/towfiqi/serpbear) 5 | 6 | #### [Documentation](https://docs.serpbear.com/) | [Changelog](https://github.com/towfiqi/serpbear/blob/main/CHANGELOG.md) | [Docker Image](https://hub.docker.com/r/towfiqi/serpbear) 7 | 8 | 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. 9 | 10 | ![Easy to Use Search Engine Rank Tracker](https://i.imgur.com/bRzpmCK.gif) 11 | 12 | #### Features 13 | - **Unlimited Keywords:** Add unlimited domains and unlimited keywords to track their SERP. 14 | - **Email Notification:** Get notified of your keyword position changes daily/weekly/monthly through email. 15 | - **SERP API:** SerpBear comes with built-in API that you can use for your marketing & data reporting tools. 16 | - **Mobile App:** Add the PWA app to your mobile for a better mobile experience. 17 | - **Zero Cost to RUN:** Run the App on mogenius.com or Fly.io for free. 18 | 19 | #### How it Works 20 | 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. 21 | 22 | #### Getting Started 23 | - **Step 1:** Deploy & Run the App. 24 | - **Step 2:** Access your App and Login. 25 | - **Step 3:** Add your First domain. 26 | - **Step 4:** Get an free API key from either ScrapingAnt or ScrapingRobot. Skip if you want to use Proxy ips. 27 | - **Step 5:** Setup the Scraping API/Proxy from the App's Settings interface. 28 | - **Step 6:** Add your keywords and start tracking. 29 | - **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. 30 | 31 | #### Compare SerpBear with other SERP tracking services 32 | 33 | |Service | Cost | SERP Lookup | API | 34 | |--|--|--|--| 35 | | SerpBear | Free* | Unlimited* | Yes | 36 | | ranktracker.com | $18/mo| 3,000/mo| No | 37 | | SerpWatch.io | $29/mo | 7500/mo | Yes | 38 | | Serpwatcher.com | $49/mo| 3000/mo | No | 39 | | whatsmyserp.com | $49/mo| 30,000/mo| No | 40 | | serply.io | $49/mo | 5000/mo | Yes | 41 | 42 | (*) Free upto a limit. If you are using ScrapingAnt you can lookup 10,000 times per month for free. 43 | 44 | **Stack** 45 | - Next.js for Frontend & Backend. 46 | - Sqlite for Database. 47 | -------------------------------------------------------------------------------- /pages/api/notify.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import nodeMailer from 'nodemailer'; 3 | import db from '../../database/database'; 4 | import Domain from '../../database/models/domain'; 5 | import Keyword from '../../database/models/keyword'; 6 | import generateEmail from '../../utils/generateEmail'; 7 | import parseKeywords from '../../utils/parseKeywords'; 8 | import { getAppSettings } from './settings'; 9 | 10 | type NotifyResponse = { 11 | success?: boolean 12 | error?: string|null, 13 | } 14 | 15 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 16 | if (req.method === 'POST') { 17 | await db.sync(); 18 | return notify(req, res); 19 | } 20 | return res.status(401).json({ success: false, error: 'Invalid Method' }); 21 | } 22 | 23 | const notify = async (req: NextApiRequest, res: NextApiResponse) => { 24 | try { 25 | const settings = await getAppSettings(); 26 | const { 27 | smtp_server = '', 28 | smtp_port = '', 29 | smtp_username = '', 30 | smtp_password = '', 31 | notification_email = '', 32 | notification_email_from = '', 33 | } = settings; 34 | 35 | if (!smtp_server || !smtp_port || !smtp_username || !smtp_password || !notification_email) { 36 | return res.status(401).json({ success: false, error: 'SMTP has not been setup properly!' }); 37 | } 38 | const fromEmail = `SerpBear <${notification_email_from || 'no-reply@serpbear.com'}>`; 39 | const transporter = nodeMailer.createTransport({ 40 | host: smtp_server, 41 | port: parseInt(smtp_port, 10), 42 | auth: { user: smtp_username, pass: smtp_password }, 43 | }); 44 | 45 | const allDomains: Domain[] = await Domain.findAll(); 46 | 47 | if (allDomains && allDomains.length > 0) { 48 | const domains = allDomains.map((el) => el.get({ plain: true })); 49 | for (const domain of domains) { 50 | if (domain.notification !== false) { 51 | const query = { where: { domain: domain.domain } }; 52 | const domainKeywords:Keyword[] = await Keyword.findAll(query); 53 | const keywordsArray = domainKeywords.map((el) => el.get({ plain: true })); 54 | const keywords: KeywordType[] = parseKeywords(keywordsArray); 55 | await transporter.sendMail({ 56 | from: fromEmail, 57 | to: domain.notification_emails || notification_email, 58 | subject: `[${domain.domain}] Keyword Positions Update`, 59 | html: await generateEmail(domain.domain, keywords), 60 | }); 61 | // console.log(JSON.stringify(result, null, 4)); 62 | } 63 | } 64 | } 65 | 66 | return res.status(200).json({ success: true, error: null }); 67 | } catch (error) { 68 | console.log(error); 69 | return res.status(401).json({ success: false, error: 'Error Sending Notification Email.' }); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /components/common/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import React, { useState } from 'react'; 3 | import toast from 'react-hot-toast'; 4 | import Icon from './Icon'; 5 | 6 | type TopbarProps = { 7 | showSettings: Function, 8 | showAddModal: Function, 9 | } 10 | 11 | const TopBar = ({ showSettings, showAddModal }:TopbarProps) => { 12 | const [showMobileMenu, setShowMobileMenu] = useState(false); 13 | const router = useRouter(); 14 | 15 | const logoutUser = async () => { 16 | try { 17 | const fetchOpts = { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }) }; 18 | const res = await fetch(`${window.location.origin}/api/logout`, fetchOpts).then((result) => result.json()); 19 | console.log(res); 20 | if (!res.success) { 21 | toast(res.error, { icon: '⚠️' }); 22 | } else { 23 | router.push('/login'); 24 | } 25 | } catch (fetchError) { 26 | toast('Could not login, Ther Server is not responsive.', { icon: '⚠️' }); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 | 33 |

34 | SerpBear 35 | 36 |

37 |
38 | 41 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default TopBar; 66 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import { useEffect, useState } from 'react'; 3 | import Head from 'next/head'; 4 | import { useRouter } from 'next/router'; 5 | // import { useEffect, useState } from 'react'; 6 | import { Toaster } from 'react-hot-toast'; 7 | import Icon from '../components/common/Icon'; 8 | import AddDomain from '../components/domains/AddDomain'; 9 | 10 | // import verifyUser from '../utils/verifyUser'; 11 | 12 | const Home: NextPage = () => { 13 | const [loading, setLoading] = useState(false); 14 | const [domains, setDomains] = useState([]); 15 | const router = useRouter(); 16 | useEffect(() => { 17 | setLoading(true); 18 | fetch(`${window.location.origin}/api/domains`) 19 | .then((result) => { 20 | if (result.status === 401) { 21 | router.push('/login'); 22 | } 23 | return result.json(); 24 | }) 25 | .then((domainsRes:any) => { 26 | if (domainsRes?.domains && domainsRes.domains.length > 0) { 27 | const firstDomainItem = domainsRes.domains[0].slug; 28 | setDomains(domainsRes.domains); 29 | router.push(`/domain/${firstDomainItem}`); 30 | } 31 | setLoading(false); 32 | return false; 33 | }) 34 | .catch((err) => { 35 | console.log(err); 36 | setLoading(false); 37 | }); 38 | }, [router]); 39 | 40 | return ( 41 |
42 | 43 | SerpBear 44 | 45 | 46 | 47 | 48 |
49 | 50 |
51 | 52 | {!loading && domains.length === 0 && console.log('Cannot Close Modal!')} />} 53 |
54 | ); 55 | }; 56 | 57 | // export const getServerSideProps = async (context:NextPageContext) => { 58 | // const { req, res } = context; 59 | // const authorized = verifyUser(req as NextApiRequest, res as NextApiResponse); 60 | // // console.log('####### authorized: ', authorized); 61 | 62 | // if (authorized !== 'authorized') { 63 | // return { redirect: { destination: '/login', permanent: false } }; 64 | // } 65 | 66 | // let domains: Domain[] = []; 67 | // try { 68 | // const fetchOpts = { method: 'GET', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; 69 | // const domainsRes = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/domains`, fetchOpts).then((result) => result.json()); 70 | // // console.log(domainsRes); 71 | 72 | // domains = domainsRes.domains; 73 | // if (domains.length > 0) { 74 | // const firstDomainItem = domains[0].slug; 75 | // return { redirect: { destination: `/domain/${firstDomainItem}`, permanent: false } }; 76 | // } 77 | // } catch (error) { 78 | // console.log(error); 79 | // } 80 | 81 | // // console.log('domains: ', domains); 82 | // return { props: { authorized, domains } }; 83 | // }; 84 | 85 | export default Home; 86 | -------------------------------------------------------------------------------- /pages/api/settings.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import Cryptr from 'cryptr'; 3 | import { writeFile, readFile } from 'fs/promises'; 4 | import verifyUser from '../../utils/verifyUser'; 5 | 6 | type SettingsGetResponse = { 7 | settings?: object | null, 8 | error?: string, 9 | } 10 | 11 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 12 | const authorized = verifyUser(req, res); 13 | if (authorized !== 'authorized') { 14 | return res.status(401).json({ error: authorized }); 15 | } 16 | if (req.method === 'GET') { 17 | return getSettings(req, res); 18 | } 19 | if (req.method === 'PUT') { 20 | return updateSettings(req, res); 21 | } 22 | return res.status(502).json({ error: 'Unrecognized Route.' }); 23 | } 24 | 25 | const getSettings = async (req: NextApiRequest, res: NextApiResponse) => { 26 | const settings = await getAppSettings(); 27 | if (settings) { 28 | return res.status(200).json({ settings }); 29 | } 30 | return res.status(400).json({ error: 'Error Loading Settings!' }); 31 | }; 32 | 33 | const updateSettings = async (req: NextApiRequest, res: NextApiResponse) => { 34 | const { settings } = req.body || {}; 35 | // console.log('### settings: ', settings); 36 | if (!settings) { 37 | return res.status(200).json({ error: 'Settings Data not Provided!' }); 38 | } 39 | try { 40 | const cryptr = new Cryptr(process.env.SECRET as string); 41 | const scaping_api = settings.scaping_api ? cryptr.encrypt(settings.scaping_api) : ''; 42 | const smtp_password = settings.smtp_password ? cryptr.encrypt(settings.smtp_password) : ''; 43 | const securedSettings = { ...settings, scaping_api, smtp_password }; 44 | 45 | await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(securedSettings), { encoding: 'utf-8' }); 46 | return res.status(200).json({ settings }); 47 | } catch (error) { 48 | console.log('ERROR updateSettings: ', error); 49 | return res.status(200).json({ error: 'Error Updating Settings!' }); 50 | } 51 | }; 52 | 53 | export const getAppSettings = async () : Promise => { 54 | try { 55 | const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' }); 56 | const settings: SettingsType = settingsRaw ? JSON.parse(settingsRaw) : {}; 57 | let decryptedSettings = settings; 58 | 59 | try { 60 | const cryptr = new Cryptr(process.env.SECRET as string); 61 | const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : ''; 62 | const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : ''; 63 | decryptedSettings = { ...settings, scaping_api, smtp_password }; 64 | } catch (error) { 65 | console.log('Error Decrypting Settings API Keys!'); 66 | } 67 | 68 | return decryptedSettings; 69 | } catch (error) { 70 | console.log(error); 71 | const settings = { 72 | scraper_type: 'none', 73 | notification_interval: 'never', 74 | notification_email: '', 75 | notification_email_from: '', 76 | smtp_server: '', 77 | smtp_port: '', 78 | smtp_username: '', 79 | smtp_password: '', 80 | }; 81 | await writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(settings), { encoding: 'utf-8' }); 82 | return settings; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /utils/sortFilter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sorrt Keywords by user's given input. 3 | * @param {KeywordType[]} theKeywords - The Keywords to sort. 4 | * @param {string} sortBy - The sort method. 5 | * @returns {KeywordType[]} 6 | */ 7 | export const sortKeywords = (theKeywords:KeywordType[], sortBy:string) : KeywordType[] => { 8 | let sortedItems = []; 9 | const keywords = theKeywords.map((k) => ({ ...k, position: k.position === 0 ? 111 : k.position })); 10 | switch (sortBy) { 11 | case 'date_asc': 12 | sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(b.added).getTime() - new Date(a.added).getTime()); 13 | break; 14 | case 'date_desc': 15 | sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => new Date(a.added).getTime() - new Date(b.added).getTime()); 16 | break; 17 | case 'pos_asc': 18 | sortedItems = keywords.sort((a: KeywordType, b: KeywordType) => (b.position > a.position ? 1 : -1)); 19 | sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position })); 20 | break; 21 | case 'pos_desc': 22 | sortedItems = keywords.sort((a: KeywordType, b: KeywordType) => (a.position > b.position ? 1 : -1)); 23 | sortedItems = sortedItems.map((k) => ({ ...k, position: k.position === 111 ? 0 : k.position })); 24 | break; 25 | case 'alpha_asc': 26 | sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (b.keyword > a.keyword ? 1 : -1)); 27 | break; 28 | case 'alpha_desc': 29 | sortedItems = theKeywords.sort((a: KeywordType, b: KeywordType) => (a.keyword > b.keyword ? 1 : -1)); 30 | break; 31 | default: 32 | return theKeywords; 33 | } 34 | 35 | // Stick Favorites item to top 36 | sortedItems = sortedItems.sort((a: KeywordType, b: KeywordType) => (b.sticky > a.sticky ? 1 : -1)); 37 | 38 | return sortedItems; 39 | }; 40 | 41 | /** 42 | * Filters the Keywords by Device when the Device buttons are switched 43 | * @param {KeywordType[]} sortedKeywords - The Sorted Keywords. 44 | * @param {string} device - Device name (desktop or mobile). 45 | * @returns {{desktop: KeywordType[], mobile: KeywordType[] } } 46 | */ 47 | export const keywordsByDevice = (sortedKeywords: KeywordType[], device: string): {[key: string]: KeywordType[] } => { 48 | const deviceKeywords: {[key:string] : KeywordType[]} = { desktop: [], mobile: [] }; 49 | sortedKeywords.forEach((keyword) => { 50 | if (keyword.device === device) { deviceKeywords[device].push(keyword); } 51 | }); 52 | return deviceKeywords; 53 | }; 54 | 55 | /** 56 | * Filters the keywords by country, search string or tags. 57 | * @param {KeywordType[]} keywords - The keywords. 58 | * @param {KeywordFilters} filterParams - The user Selected filter object. 59 | * @returns {KeywordType[]} 60 | */ 61 | export const filterKeywords = (keywords: KeywordType[], filterParams: KeywordFilters):KeywordType[] => { 62 | const filteredItems:KeywordType[] = []; 63 | keywords.forEach((keywrd) => { 64 | const countryMatch = filterParams.countries.length === 0 ? true : filterParams.countries && filterParams.countries.includes(keywrd.country); 65 | const searchMatch = !filterParams.search ? true : filterParams.search && keywrd.keyword.includes(filterParams.search); 66 | const tagsMatch = filterParams.tags.length === 0 ? true : filterParams.tags && keywrd.tags.find((x) => filterParams.tags.includes(x)); 67 | 68 | if (countryMatch && searchMatch && tagsMatch) { 69 | filteredItems.push(keywrd); 70 | } 71 | }); 72 | 73 | return filteredItems; 74 | }; 75 | -------------------------------------------------------------------------------- /components/keywords/KeywordTagManager.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useUpdateKeywordTags } from '../../services/keywords'; 3 | import Icon from '../common/Icon'; 4 | import Modal from '../common/Modal'; 5 | 6 | type keywordTagManagerProps = { 7 | keyword: KeywordType|undefined, 8 | closeModal: Function, 9 | allTags: string[] 10 | } 11 | 12 | const KeywordTagManager = ({ keyword, closeModal }: keywordTagManagerProps) => { 13 | const [tagInput, setTagInput] = useState(''); 14 | const [inputError, setInputError] = useState(''); 15 | const { mutate: updateMutate } = useUpdateKeywordTags(() => { setTagInput(''); }); 16 | 17 | const removeTag = (tag:String) => { 18 | if (!keyword) { return; } 19 | const newTags = keyword.tags.filter((t) => t !== tag.trim()); 20 | updateMutate({ tags: { [keyword.ID]: newTags } }); 21 | }; 22 | 23 | const addTag = () => { 24 | if (!keyword) { return; } 25 | if (!tagInput) { 26 | setInputError('Please Insert a Tag!'); 27 | setTimeout(() => { setInputError(''); }, 3000); 28 | return; 29 | } 30 | if (keyword.tags.includes(tagInput)) { 31 | setInputError('Tag Exist!'); 32 | setTimeout(() => { setInputError(''); }, 3000); 33 | return; 34 | } 35 | 36 | console.log('New Tag: ', tagInput); 37 | const newTags = [...keyword.tags, tagInput.trim()]; 38 | updateMutate({ tags: { [keyword.ID]: newTags } }); 39 | }; 40 | 41 | return ( 42 | { closeModal(false); }} title={`Tags for Keyword "${keyword && keyword.keyword}"`}> 43 |
44 | {keyword && keyword.tags.length > 0 && ( 45 |
    46 | {keyword.tags.map((tag:string) => { 47 | return
  • 48 | {tag} 49 | 54 |
  • ; 55 | })} 56 |
57 | )} 58 | {keyword && keyword.tags.length === 0 && ( 59 |
No Tags Added to this Keyword.
60 | )} 61 |
62 |
63 | {inputError && {inputError}} 64 | 65 | setTagInput(e.target.value)} 70 | onKeyDown={(e) => { 71 | if (e.code === 'Enter') { 72 | e.preventDefault(); 73 | addTag(); 74 | } 75 | }} 76 | /> 77 | 78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | export default KeywordTagManager; 85 | -------------------------------------------------------------------------------- /services/domains.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter, NextRouter } from 'next/router'; 2 | import toast from 'react-hot-toast'; 3 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 4 | 5 | type UpdatePayload = { 6 | domainSettings: DomainSettings, 7 | domain: Domain 8 | } 9 | 10 | export async function fetchDomains(router: NextRouter) { 11 | const res = await fetch(`${window.location.origin}/api/domains`, { method: 'GET' }); 12 | if (res.status >= 400 && res.status < 600) { 13 | if (res.status === 401) { 14 | console.log('Unauthorized!!'); 15 | router.push('/login'); 16 | } 17 | throw new Error('Bad response from server'); 18 | } 19 | return res.json(); 20 | } 21 | 22 | export function useFetchDomains(router: NextRouter) { 23 | return useQuery('domains', () => fetchDomains(router)); 24 | } 25 | 26 | export function useAddDomain(onSuccess:Function) { 27 | const router = useRouter(); 28 | const queryClient = useQueryClient(); 29 | return useMutation(async (domainName:string) => { 30 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 31 | const fetchOpts = { method: 'POST', headers, body: JSON.stringify({ domain: domainName }) }; 32 | const res = await fetch(`${window.location.origin}/api/domains`, fetchOpts); 33 | if (res.status >= 400 && res.status < 600) { 34 | throw new Error('Bad response from server'); 35 | } 36 | return res.json(); 37 | }, { 38 | onSuccess: async (data) => { 39 | console.log('Domain Added!!!', data); 40 | const newDomain:Domain = data.domain; 41 | toast(`${newDomain.domain} Added Successfully!`, { icon: '✔️' }); 42 | onSuccess(false); 43 | if (newDomain && newDomain.slug) { 44 | router.push(`/domain/${data.domain.slug}`); 45 | } 46 | queryClient.invalidateQueries(['domains']); 47 | }, 48 | onError: () => { 49 | console.log('Error Adding New Domain!!!'); 50 | toast('Error Adding New Domain'); 51 | }, 52 | }); 53 | } 54 | 55 | export function useUpdateDomain(onSuccess:Function) { 56 | const queryClient = useQueryClient(); 57 | return useMutation(async ({ domainSettings, domain }: UpdatePayload) => { 58 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 59 | const fetchOpts = { method: 'PUT', headers, body: JSON.stringify(domainSettings) }; 60 | const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, fetchOpts); 61 | if (res.status >= 400 && res.status < 600) { 62 | throw new Error('Bad response from server'); 63 | } 64 | return res.json(); 65 | }, { 66 | onSuccess: async () => { 67 | console.log('Settings Updated!!!'); 68 | toast('Settings Updated!', { icon: '✔️' }); 69 | onSuccess(); 70 | queryClient.invalidateQueries(['domains']); 71 | }, 72 | onError: () => { 73 | console.log('Error Updating Domain Settings!!!'); 74 | toast('Error Updating Domain Settings', { icon: '⚠️' }); 75 | }, 76 | }); 77 | } 78 | 79 | export function useDeleteDomain(onSuccess:Function) { 80 | const queryClient = useQueryClient(); 81 | return useMutation(async (domain:Domain) => { 82 | const res = await fetch(`${window.location.origin}/api/domains?domain=${domain.domain}`, { method: 'DELETE' }); 83 | if (res.status >= 400 && res.status < 600) { 84 | throw new Error('Bad response from server'); 85 | } 86 | return res.json(); 87 | }, { 88 | onSuccess: async () => { 89 | toast('Domain Removed Successfully!', { icon: '✔️' }); 90 | onSuccess(); 91 | queryClient.invalidateQueries(['domains']); 92 | }, 93 | onError: () => { 94 | console.log('Error Removing Domain!!!'); 95 | toast('Error Removing Domain', { icon: '⚠️' }); 96 | }, 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /components/domains/DomainHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useState } from 'react'; 3 | import { useRefreshKeywords } from '../../services/keywords'; 4 | import Icon from '../common/Icon'; 5 | import SelectField from '../common/SelectField'; 6 | 7 | type DomainHeaderProps = { 8 | domain: Domain, 9 | domains: Domain[], 10 | showAddModal: Function, 11 | showSettingsModal: Function, 12 | exportCsv:Function 13 | } 14 | 15 | const DomainHeader = ({ domain, showAddModal, showSettingsModal, exportCsv, domains }: DomainHeaderProps) => { 16 | const router = useRouter(); 17 | const [showOptions, setShowOptions] = useState(false); 18 | 19 | const { mutate: refreshMutate } = useRefreshKeywords(() => {}); 20 | 21 | const buttonStyle = 'leading-6 inline-block px-2 py-2 text-gray-500 hover:text-gray-700'; 22 | const buttonLabelStyle = 'ml-2 text-sm not-italic lg:invisible lg:opacity-0'; 23 | return ( 24 |
25 |
26 |

27 | {domain && domain.domain && <>{domain.domain.charAt(0)}{domain.domain.slice(1)}} 28 |

29 |
30 | 0 ? domains.map((d) => { return { label: d.domain, value: d.slug }; }) : []} 32 | selected={[domain.slug]} 33 | defaultLabel="Select Domain" 34 | updateField={(updateSlug:[string]) => updateSlug && updateSlug[0] && router.push(`${updateSlug[0]}`)} 35 | multiple={false} 36 | rounded={'rounded'} 37 | /> 38 |
39 |
40 |
41 | 44 |
48 | 54 | 60 | 66 |
67 | 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default DomainHeader; 81 | -------------------------------------------------------------------------------- /pages/api/domains.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import db from '../../database/database'; 3 | import Domain from '../../database/models/domain'; 4 | import Keyword from '../../database/models/keyword'; 5 | import verifyUser from '../../utils/verifyUser'; 6 | 7 | type DomainsGetRes = { 8 | domains: Domain[] 9 | error?: string|null, 10 | } 11 | 12 | type DomainsAddResponse = { 13 | domain: Domain|null, 14 | error?: string|null, 15 | } 16 | 17 | type DomainsDeleteRes = { 18 | domainRemoved: number, 19 | keywordsRemoved: number, 20 | error?: string|null, 21 | } 22 | 23 | type DomainsUpdateRes = { 24 | domain: Domain|null, 25 | error?: string|null, 26 | } 27 | 28 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 29 | await db.sync(); 30 | const authorized = verifyUser(req, res); 31 | if (authorized !== 'authorized') { 32 | return res.status(401).json({ error: authorized }); 33 | } 34 | if (req.method === 'GET') { 35 | return getDomains(req, res); 36 | } 37 | if (req.method === 'POST') { 38 | return addDomain(req, res); 39 | } 40 | if (req.method === 'DELETE') { 41 | return deleteDomain(req, res); 42 | } 43 | if (req.method === 'PUT') { 44 | return updateDomain(req, res); 45 | } 46 | return res.status(502).json({ error: 'Unrecognized Route.' }); 47 | } 48 | 49 | export const getDomains = async (req: NextApiRequest, res: NextApiResponse) => { 50 | try { 51 | const allDomains: Domain[] = await Domain.findAll(); 52 | return res.status(200).json({ domains: allDomains }); 53 | } catch (error) { 54 | return res.status(400).json({ domains: [], error: 'Error Getting Domains.' }); 55 | } 56 | }; 57 | 58 | export const addDomain = async (req: NextApiRequest, res: NextApiResponse) => { 59 | if (!req.body.domain) { 60 | return res.status(400).json({ domain: null, error: 'Error Adding Domain.' }); 61 | } 62 | const { domain } = req.body || {}; 63 | const domainData = { 64 | domain: domain.trim(), 65 | slug: domain.trim().replaceAll('-', '_').replaceAll('.', '-'), 66 | lastUpdated: new Date().toJSON(), 67 | added: new Date().toJSON(), 68 | }; 69 | 70 | try { 71 | const addedDomain = await Domain.create(domainData); 72 | return res.status(201).json({ domain: addedDomain }); 73 | } catch (error) { 74 | return res.status(400).json({ domain: null, error: 'Error Adding Domain.' }); 75 | } 76 | }; 77 | 78 | export const deleteDomain = async (req: NextApiRequest, res: NextApiResponse) => { 79 | if (!req.query.domain && typeof req.query.domain !== 'string') { 80 | return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Domain is Required!' }); 81 | } 82 | try { 83 | const { domain } = req.query || {}; 84 | const removedDomCount: number = await Domain.destroy({ where: { domain } }); 85 | const removedKeywordCount: number = await Keyword.destroy({ where: { domain } }); 86 | return res.status(200).json({ 87 | domainRemoved: removedDomCount, 88 | keywordsRemoved: removedKeywordCount, 89 | }); 90 | } catch (error) { 91 | console.log('##### Delete Domain Error: ', error); 92 | return res.status(400).json({ domainRemoved: 0, keywordsRemoved: 0, error: 'Error Deleting Domain' }); 93 | } 94 | }; 95 | 96 | export const updateDomain = async (req: NextApiRequest, res: NextApiResponse) => { 97 | if (!req.query.domain) { 98 | return res.status(400).json({ domain: null, error: 'Domain is Required!' }); 99 | } 100 | const { domain } = req.query || {}; 101 | const { notification_interval, notification_emails } = req.body; 102 | 103 | const domainToUpdate: Domain|null = await Domain.findOne({ where: { domain } }); 104 | if (domainToUpdate) { 105 | domainToUpdate.set({ notification_interval, notification_emails }); 106 | await domainToUpdate.save(); 107 | } 108 | 109 | return res.status(200).json({ domain: domainToUpdate }); 110 | }; 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 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. 4 | 5 | ### [0.1.6](https://github.com/towfiqi/serpbear/compare/v0.1.5...v0.1.6) (2022-12-05) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * CSS Linter issues. ([a599035](https://github.com/towfiqi/serpbear/commit/a59903551eccb3f03f2bc026673bbf9fd0d4bc1e)) 11 | * invalid json markup ([e9d7730](https://github.com/towfiqi/serpbear/commit/e9d7730ae7ec647d333713248b271bae8693e77b)) 12 | * 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) 13 | * **UI:** Adds tooltip for Domain action icons. ([b450540](https://github.com/towfiqi/serpbear/commit/b450540d9593d022c94708c9679b5bf7c0279c50)) 14 | 15 | ### [0.1.5](https://github.com/towfiqi/serpbear/compare/v0.1.4...v0.1.5) (2022-12-03) 16 | 17 | 18 | ### Features 19 | 20 | * keyword not in first 100 now shows >100 ([e1799fb](https://github.com/towfiqi/serpbear/commit/e1799fb2f35ab8c0f65eb90e66dcda10b8cb6f16)) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * domains with - were not loading the keywords. ([efb565b](https://github.com/towfiqi/serpbear/commit/efb565ba0086d1b3e69ea71456a892ca254856f7)), closes [#11](https://github.com/towfiqi/serpbear/issues/11) 26 | * failed scrape messes up lastResult data in db ([dd6a801](https://github.com/towfiqi/serpbear/commit/dd6a801ffda3eacda957dd20d2c97fb6197fbdc2)) 27 | * First search result items were being skipped. ([d6da18f](https://github.com/towfiqi/serpbear/commit/d6da18fb0135e23dd869d1fb500e12ee2e782bfa)), closes [#13](https://github.com/towfiqi/serpbear/issues/13) 28 | * removes empty spaces when adding domain. ([a11b0f2](https://github.com/towfiqi/serpbear/commit/a11b0f223c0647537ab23564df1d2f0b29eef4ae)) 29 | 30 | ### [0.1.4](https://github.com/towfiqi/serpbear/compare/v0.1.3...v0.1.4) (2022-12-01) 31 | 32 | 33 | ### Features 34 | 35 | * Failed scrape now shows error details in UI. ([8c8064f](https://github.com/towfiqi/serpbear/commit/8c8064f222ea8177b26b6dd28866d1f421faca39)) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * Domains with www weren't loading keywords. ([3d1c690](https://github.com/towfiqi/serpbear/commit/3d1c690076a03598f0ac3f3663d905479d945897)), closes [#8](https://github.com/towfiqi/serpbear/issues/8) 41 | * Emails were sending serps of previous day. ([6910558](https://github.com/towfiqi/serpbear/commit/691055811c2ae70ce1b878346300048c1e23f2eb)) 42 | * Fixes Broken ScrapingRobot Integration. ([1ed298f](https://github.com/towfiqi/serpbear/commit/1ed298f633a9ae5b402b431f1e50b35ffd44a6dc)) 43 | * scraper fails if matched domain has www ([38dc164](https://github.com/towfiqi/serpbear/commit/38dc164514b066b2007f2f3b2ae68005621963cc)), closes [#6](https://github.com/towfiqi/serpbear/issues/6) [#7](https://github.com/towfiqi/serpbear/issues/7) 44 | * scraper fails when result has domain w/o www ([6d7cfec](https://github.com/towfiqi/serpbear/commit/6d7cfec95304fa7a61beaab07f7cd6af215255c3)) 45 | 46 | ### [0.1.3](https://github.com/towfiqi/serpbear/compare/v0.1.2...v0.1.3) (2022-12-01) 47 | 48 | 49 | ### Features 50 | 51 | * Adds a search field in Country select field. ([be4db26](https://github.com/towfiqi/serpbear/commit/be4db26316e7522f567a4ce6fc27e0a0f73f89f2)), closes [#2](https://github.com/towfiqi/serpbear/issues/2) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * could not add 2 character domains. ([5acbe18](https://github.com/towfiqi/serpbear/commit/5acbe181ec978b50b588af378d17fb3070c241d1)), closes [#1](https://github.com/towfiqi/serpbear/issues/1) 57 | * license location. ([a45237b](https://github.com/towfiqi/serpbear/commit/a45237b230a9830461cf7fccd4c717235112713b)) 58 | * No hint on how to add multiple keywords. ([9fa80cf](https://github.com/towfiqi/serpbear/commit/9fa80cf6098854d2a5bd5a8202aa0fd6886d1ba0)), closes [#3](https://github.com/towfiqi/serpbear/issues/3) 59 | 60 | ### 0.1.2 (2022-11-30) 61 | 62 | 63 | ### Bug Fixes 64 | 65 | * API URL should be dynamic. ([d687109](https://github.com/towfiqi/serpbear/commit/d6871097a2e8925a4222e7780f21e6088b8df467)) 66 | * Email Notification wasn't working. ([365caec](https://github.com/towfiqi/serpbear/commit/365caecc34be4630241189d1a948133254d0047a)) 67 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url("./fflag.css"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | body { 8 | background-color: #f8f9ff; 9 | } 10 | 11 | .domKeywords { 12 | min-height: 70vh; 13 | border-color: #e9ebff; 14 | box-shadow: 0 0 20px rgb(20 34 71 / 5%); 15 | } 16 | 17 | .customShadow { 18 | border-color: #e9ebff; 19 | box-shadow: 0 0 20px rgb(20 34 71 / 5%); 20 | } 21 | 22 | .styled-scrollbar { 23 | scrollbar-color: #d6dbec transparent; 24 | scrollbar-width: thin; 25 | } 26 | 27 | .styled-scrollbar::-webkit-scrollbar { 28 | width: 6px; 29 | height: 6px; 30 | border-radius: 0; 31 | background: #f5f7ff; 32 | margin-right: 4px; 33 | border: 0 solid transparent; 34 | } 35 | 36 | .styled-scrollbar::-webkit-scrollbar-thumb { 37 | width: 6px; 38 | height: 6px; 39 | border-radius: 0; 40 | color: #d6dbec; 41 | background: #d6d8e1; 42 | border: 0 solid transparent; 43 | box-shadow: none; 44 | } 45 | 46 | .ct-area { 47 | fill: #10b98d73; 48 | } 49 | 50 | .ct-label.ct-horizontal { 51 | font-size: 11px; 52 | } 53 | 54 | .ct-label.ct-vertical { 55 | font-size: 12px; 56 | } 57 | 58 | .chart_tooltip { 59 | width: 95px; 60 | height: 75px; 61 | background-color: white; 62 | position: absolute; 63 | display: none; 64 | padding: 0 8px; 65 | box-sizing: border-box; 66 | font-size: 12px; 67 | text-align: left; 68 | z-index: 1000; 69 | top: 12px; 70 | left: 12px; 71 | pointer-events: none; 72 | border: 1px solid; 73 | border-radius: 4px; 74 | border-color: #a7aed3; 75 | box-shadow: 0 0 10px rgb(0 0 0 / 12%); 76 | font-family: "Trebuchet MS", Roboto, Ubuntu, sans-serif; 77 | -webkit-font-smoothing: antialiased; 78 | -moz-osx-font-smoothing: grayscale; 79 | } 80 | 81 | .react_toaster { 82 | font-size: 13px; 83 | } 84 | 85 | .domKeywords_head--alpha_desc .domKeywords_head_keyword::after, 86 | .domKeywords_head--pos_desc .domKeywords_head_position::after { 87 | content: "↓"; 88 | display: inline-block; 89 | margin-left: 2px; 90 | font-size: 14px; 91 | opacity: 0.8; 92 | } 93 | 94 | .domKeywords_head--alpha_asc .domKeywords_head_keyword::after, 95 | .domKeywords_head--pos_asc .domKeywords_head_position::after { 96 | content: "↑"; 97 | display: inline-block; 98 | margin-left: 2px; 99 | font-size: 14px; 100 | opacity: 0.8; 101 | } 102 | 103 | .keywordDetails__section__results { 104 | height: calc(100vh - 550px); 105 | } 106 | 107 | .settings__content { 108 | height: calc(100vh - 185px); 109 | overflow: auto; 110 | } 111 | 112 | /* Animation */ 113 | .modal_anim-enter { 114 | opacity: 0; 115 | } 116 | 117 | .modal_anim-enter-active { 118 | opacity: 1; 119 | transition: opacity 300ms; 120 | } 121 | 122 | .modal_anim-enter .modal__content { 123 | transform: translateY(50px); 124 | } 125 | 126 | .modal_anim-enter-active .modal__content { 127 | transform: translateY(0); 128 | transition: all 300ms; 129 | } 130 | 131 | .modal_anim-exit { 132 | opacity: 1; 133 | } 134 | 135 | .modal_anim-exit-active { 136 | opacity: 0; 137 | transition: all 300ms; 138 | } 139 | 140 | .modal_anim-exit .modal__content { 141 | transform: translateY(0); 142 | } 143 | 144 | .modal_anim-exit-active .modal__content { 145 | transform: translateY(50px); 146 | transition: all 300ms; 147 | } 148 | 149 | .settings_anim-enter { 150 | opacity: 0; 151 | transform: translateX(400px); 152 | } 153 | 154 | .settings_anim-enter-active { 155 | opacity: 1; 156 | transform: translateX(0); 157 | transition: all 300ms; 158 | } 159 | 160 | .settings_anim-exit { 161 | opacity: 1; 162 | transform: translateX(0); 163 | } 164 | 165 | .settings_anim-exit-active { 166 | opacity: 0; 167 | transform: translateX(400px); 168 | transition: all 300ms; 169 | } 170 | 171 | @media (min-width: 1024px) { 172 | /* Domain Header Button Tooltips */ 173 | .domheader_action_button:hover i { 174 | visibility: visible; 175 | opacity: 1; 176 | } 177 | 178 | .domheader_action_button i { 179 | display: block; 180 | position: absolute; 181 | width: 100px; 182 | left: -40px; 183 | top: -22px; 184 | background: #222; 185 | border-radius: 3px; 186 | color: #fff; 187 | font-size: 12px; 188 | padding-bottom: 3px; 189 | transition: all 0.2s linear; 190 | } 191 | 192 | .domheader_action_button i::after { 193 | content: ""; 194 | width: 0; 195 | height: 0; 196 | border-style: solid; 197 | border-width: 5px 5px 0; 198 | border-color: #222 transparent transparent; 199 | bottom: -5px; 200 | position: absolute; 201 | left: 0; 202 | right: 0; 203 | margin: 0 auto; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /utils/generateEmail.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { readFile } from 'fs/promises'; 3 | import path from 'path'; 4 | 5 | const serpBearLogo = 'https://i.imgur.com/ikAdjQq.png'; 6 | const mobileIcon = 'https://i.imgur.com/SqXD9rd.png'; 7 | const desktopIcon = 'https://i.imgur.com/Dx3u0XD.png'; 8 | 9 | /** 10 | * Geenrate Human readable Time string. 11 | * @param {number} date - Keywords to scrape 12 | * @returns {string} 13 | */ 14 | const timeSince = (date:number) : string => { 15 | const seconds = Math.floor(((new Date().getTime() / 1000) - date)); 16 | let interval = Math.floor(seconds / 31536000); 17 | 18 | if (interval > 1) return `${interval} years ago`; 19 | 20 | interval = Math.floor(seconds / 2592000); 21 | if (interval > 1) return `${interval} months ago`; 22 | 23 | interval = Math.floor(seconds / 86400); 24 | if (interval >= 1) return `${interval} days ago`; 25 | 26 | interval = Math.floor(seconds / 3600); 27 | if (interval >= 1) return `${interval} hours ago`; 28 | 29 | interval = Math.floor(seconds / 60); 30 | if (interval > 1) return `${interval} minutes ago`; 31 | 32 | return `${Math.floor(seconds)} seconds ago`; 33 | }; 34 | 35 | /** 36 | * Returns a Keyword's position change value by comparing the current position with previous position. 37 | * @param {KeywordHistory} history - Keywords to scrape 38 | * @param {number} position - Keywords to scrape 39 | * @returns {number} 40 | */ 41 | const getPositionChange = (history:KeywordHistory, position:number) : number => { 42 | let status = 0; 43 | if (Object.keys(history).length >= 2) { 44 | const historyArray = Object.keys(history).map((dateKey) => ({ 45 | date: new Date(dateKey).getTime(), 46 | dateRaw: dateKey, 47 | position: history[dateKey], 48 | })); 49 | const historySorted = historyArray.sort((a, b) => a.date - b.date); 50 | const previousPos = historySorted[historySorted.length - 2].position; 51 | status = previousPos - position; 52 | } 53 | return status; 54 | }; 55 | 56 | /** 57 | * Generate the Email HTML based on given domain name and its keywords 58 | * @param {string} domainName - Keywords to scrape 59 | * @param {keywords[]} keywords - Keywords to scrape 60 | * @returns {Promise} 61 | */ 62 | const generateEmail = async (domainName:string, keywords:KeywordType[]) : Promise => { 63 | const emailTemplate = await readFile(path.join(__dirname, '..', '..', '..', '..', 'email', 'email.html'), { encoding: 'utf-8' }); 64 | const currentDate = dayjs(new Date()).format('MMMM D, YYYY'); 65 | const keywordsCount = keywords.length; 66 | let improved = 0; let declined = 0; 67 | 68 | let keywordsTable = ''; 69 | 70 | keywords.forEach((keyword) => { 71 | let positionChangeIcon = ''; 72 | 73 | const positionChange = getPositionChange(keyword.history, keyword.position); 74 | const deviceIconImg = keyword.device === 'desktop' ? desktopIcon : mobileIcon; 75 | const countryFlag = `${keyword.country}`; 76 | const deviceIcon = `${keyword.device}`; 77 | 78 | if (positionChange > 0) { positionChangeIcon = ''; improved += 1; } 79 | if (positionChange < 0) { positionChangeIcon = ''; declined += 1; } 80 | 81 | const posChangeIcon = positionChange ? `${positionChangeIcon} ${positionChange}` : ''; 82 | keywordsTable += ` 83 | ${countryFlag} ${deviceIcon} ${keyword.keyword} 84 | ${keyword.position}${posChangeIcon} 85 | ${timeSince(new Date(keyword.lastUpdated).getTime() / 1000)} 86 | `; 87 | }); 88 | 89 | const stat = `${improved > 0 ? `${improved} Improved` : ''} 90 | ${improved > 0 && declined > 0 ? ', ' : ''} ${declined > 0 ? `${declined} Declined` : ''}`; 91 | const updatedEmail = emailTemplate 92 | .replace('{{logo}}', `SerpBear`) 93 | .replace('{{currentDate}}', currentDate) 94 | .replace('{{domainName}}', domainName) 95 | .replace('{{keywordsCount}}', keywordsCount.toString()) 96 | .replace('{{keywordsTable}}', keywordsTable) 97 | .replace('{{appURL}}', process.env.NEXT_PUBLIC_APP_URL || '') 98 | .replace('{{stat}}', stat) 99 | .replace('{{preheader}}', stat); 100 | 101 | return updatedEmail; 102 | }; 103 | 104 | export default generateEmail; 105 | -------------------------------------------------------------------------------- /pages/domain/[slug]/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import type { NextPage } from 'next'; 3 | import Head from 'next/head'; 4 | import { useRouter } from 'next/router'; 5 | // import { useQuery } from 'react-query'; 6 | // import toast from 'react-hot-toast'; 7 | import { CSSTransition } from 'react-transition-group'; 8 | import Sidebar from '../../../components/common/Sidebar'; 9 | import TopBar from '../../../components/common/TopBar'; 10 | import DomainHeader from '../../../components/domains/DomainHeader'; 11 | import KeywordsTable from '../../../components/keywords/KeywordsTable'; 12 | import AddDomain from '../../../components/domains/AddDomain'; 13 | import DomainSettings from '../../../components/domains/DomainSettings'; 14 | import exportCSV from '../../../utils/exportcsv'; 15 | import Settings from '../../../components/settings/Settings'; 16 | import { useFetchDomains } from '../../../services/domains'; 17 | import { useFetchKeywords } from '../../../services/keywords'; 18 | import { useFetchSettings } from '../../../services/settings'; 19 | 20 | const SingleDomain: NextPage = () => { 21 | const router = useRouter(); 22 | const [noScrapprtError, setNoScrapprtError] = useState(false); 23 | const [showAddKeywords, setShowAddKeywords] = useState(false); 24 | const [showAddDomain, setShowAddDomain] = useState(false); 25 | const [showDomainSettings, setShowDomainSettings] = useState(false); 26 | const [showSettings, setShowSettings] = useState(false); 27 | const [keywordSPollInterval, setKeywordSPollInterval] = useState(undefined); 28 | const { data: appSettings } = useFetchSettings(); 29 | const { data: domainsData } = useFetchDomains(router); 30 | const { keywordsData, keywordsLoading } = useFetchKeywords(router, setKeywordSPollInterval, keywordSPollInterval); 31 | 32 | const theDomains: Domain[] = (domainsData && domainsData.domains) || []; 33 | const theKeywords: KeywordType[] = keywordsData && keywordsData.keywords; 34 | 35 | const activDomain: Domain|null = useMemo(() => { 36 | let active:Domain|null = null; 37 | if (domainsData?.domains && router.query?.slug) { 38 | active = domainsData.domains.find((x:Domain) => x.slug === router.query.slug); 39 | } 40 | return active; 41 | }, [router.query.slug, domainsData]); 42 | 43 | useEffect(() => { 44 | console.log('appSettings.settings: ', appSettings && appSettings.settings); 45 | if (appSettings && appSettings.settings && (!appSettings.settings.scraper_type || (appSettings.settings.scraper_type === 'none'))) { 46 | setNoScrapprtError(true); 47 | } 48 | }, [appSettings]); 49 | 50 | // console.log('Domains Data:', router, activDomain, theKeywords); 51 | 52 | return ( 53 |
54 | {noScrapprtError && ( 55 |
56 | A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app. 57 |
58 | )} 59 | {activDomain && activDomain.domain 60 | && 61 | {`${activDomain.domain} - SerpBear` } 62 | 63 | } 64 | setShowSettings(true)} showAddModal={() => setShowAddDomain(true)} /> 65 |
66 | setShowAddDomain(true)} /> 67 |
68 | {activDomain && activDomain.domain 69 | && exportCSV(theKeywords, activDomain.domain)} 75 | />} 76 | 83 |
84 |
85 | 86 | 87 | setShowAddDomain(false)} /> 88 | 89 | 90 | 91 | 96 | 97 | 98 | setShowSettings(false)} /> 99 | 100 |
101 | ); 102 | }; 103 | 104 | export default SingleDomain; 105 | -------------------------------------------------------------------------------- /cron.js: -------------------------------------------------------------------------------- 1 | const Cryptr = require('cryptr'); 2 | const { promises } = require('fs'); 3 | const { readFile } = require('fs'); 4 | const cron = require('node-cron'); 5 | require('dotenv').config({ path: './.env.local' }); 6 | 7 | const getAppSettings = async () => { 8 | const defaultSettings = { 9 | scraper_type: 'none', 10 | notification_interval: 'never', 11 | notification_email: '', 12 | smtp_server: '', 13 | smtp_port: '', 14 | smtp_username: '', 15 | smtp_password: '', 16 | }; 17 | // console.log('process.env.SECRET: ', process.env.SECRET); 18 | try { 19 | let decryptedSettings = {}; 20 | const exists = await promises.stat(`${process.cwd()}/data/settings.json`).then(() => true).catch(() => false); 21 | if (exists) { 22 | const settingsRaw = await promises.readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' }); 23 | const settings = settingsRaw ? JSON.parse(settingsRaw) : {}; 24 | 25 | try { 26 | const cryptr = new Cryptr(process.env.SECRET); 27 | const scaping_api = settings.scaping_api ? cryptr.decrypt(settings.scaping_api) : ''; 28 | const smtp_password = settings.smtp_password ? cryptr.decrypt(settings.smtp_password) : ''; 29 | decryptedSettings = { ...settings, scaping_api, smtp_password }; 30 | } catch (error) { 31 | console.log('Error Decrypting Settings API Keys!'); 32 | } 33 | } else { 34 | throw Error('Settings file dont exist.'); 35 | } 36 | return decryptedSettings; 37 | } catch (error) { 38 | console.log(error); 39 | 40 | await promises.writeFile(`${process.cwd()}/data/settings.json`, JSON.stringify(defaultSettings), { encoding: 'utf-8' }); 41 | return defaultSettings; 42 | } 43 | }; 44 | 45 | const generateCronTime = (interval) => { 46 | let cronTime = false; 47 | if (interval === 'hourly') { 48 | cronTime = '0 0 */1 * * *'; 49 | } 50 | if (interval === 'daily') { 51 | cronTime = '0 0 0 * * *'; 52 | } 53 | if (interval === 'daily_morning') { 54 | cronTime = '0 0 0 7 * *'; 55 | } 56 | if (interval === 'weekly') { 57 | cronTime = '0 0 0 */7 * *'; 58 | } 59 | if (interval === 'monthly') { 60 | cronTime = '0 0 1 * *'; // Run every first day of the month at 00:00(midnight) 61 | } 62 | 63 | return cronTime; 64 | }; 65 | 66 | const runAppCronJobs = () => { 67 | // RUN SERP Scraping CRON (EveryDay at Midnight) 0 0 0 * * 68 | const scrapeCronTime = generateCronTime('daily'); 69 | cron.schedule(scrapeCronTime, () => { 70 | // console.log('### Running Keyword Position Cron Job!'); 71 | const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; 72 | fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/cron`, fetchOpts) 73 | .then((res) => res.json()) 74 | .then((data) => console.log(data)) 75 | .catch((err) => { 76 | console.log('ERROR Making Cron Request..'); 77 | console.log(err); 78 | }); 79 | }, { scheduled: true }); 80 | 81 | // Run Failed scraping CRON (Every Hour) 82 | const failedCronTime = generateCronTime('hourly'); 83 | cron.schedule(failedCronTime, () => { 84 | // console.log('### Retrying Failed Scrapes...'); 85 | 86 | readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' }, (err, data) => { 87 | if (data) { 88 | const keywordsToRetry = data ? JSON.parse(data) : []; 89 | if (keywordsToRetry.length > 0) { 90 | const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; 91 | fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/refresh?id=${keywordsToRetry.join(',')}`, fetchOpts) 92 | .then((res) => res.json()) 93 | .then((refreshedData) => console.log(refreshedData)) 94 | .catch((fetchErr) => { 95 | console.log('ERROR Making Cron Request..'); 96 | console.log(fetchErr); 97 | }); 98 | } 99 | } else { 100 | console.log('ERROR Reading Failed Scrapes Queue File..', err); 101 | } 102 | }); 103 | }, { scheduled: true }); 104 | 105 | // RUN Email Notification CRON 106 | getAppSettings().then((settings) => { 107 | const notif_interval = (!settings.notification_interval || settings.notification_interval === 'never') ? false : settings.notification_interval; 108 | if (notif_interval) { 109 | const cronTime = generateCronTime(notif_interval === 'daily' ? 'daily_morning' : notif_interval); 110 | if (cronTime) { 111 | cron.schedule(cronTime, () => { 112 | // console.log('### Sending Notification Email...'); 113 | const fetchOpts = { method: 'POST', headers: { Authorization: `Bearer ${process.env.APIKEY}` } }; 114 | fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/notify`, fetchOpts) 115 | .then((res) => res.json()) 116 | .then((data) => console.log(data)) 117 | .catch((err) => { 118 | console.log('ERROR Making Cron Request..'); 119 | console.log(err); 120 | }); 121 | }, { scheduled: true }); 122 | } 123 | } 124 | }); 125 | }; 126 | 127 | runAppCronJobs(); 128 | -------------------------------------------------------------------------------- /components/domains/DomainSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect, useState } from 'react'; 3 | import Icon from '../common/Icon'; 4 | import Modal from '../common/Modal'; 5 | import { useDeleteDomain, useUpdateDomain } from '../../services/domains'; 6 | 7 | type DomainSettingsProps = { 8 | domain:Domain|false, 9 | domains: Domain[], 10 | closeModal: Function 11 | } 12 | 13 | type DomainSettingsError = { 14 | type: string, 15 | msg: string, 16 | } 17 | 18 | const DomainSettings = ({ domain, domains, closeModal }: DomainSettingsProps) => { 19 | const router = useRouter(); 20 | const [showRemoveDomain, setShowRemoveDomain] = useState(false); 21 | const [settingsError, setSettingsError] = useState({ type: '', msg: '' }); 22 | const [domainSettings, setDomainSettings] = useState({ notification_interval: 'never', notification_emails: '' }); 23 | 24 | const { mutate: updateMutate } = useUpdateDomain(() => closeModal(false)); 25 | const { mutate: deleteMutate } = useDeleteDomain(() => { 26 | closeModal(false); 27 | const fitleredDomains = domain && domains.filter((d:Domain) => d.domain !== domain.domain); 28 | if (fitleredDomains && fitleredDomains[0] && fitleredDomains[0].slug) { 29 | router.push(`/domain/${fitleredDomains[0].slug}`); 30 | } 31 | }); 32 | 33 | useEffect(() => { 34 | if (domain) { 35 | setDomainSettings({ notification_interval: domain.notification_interval, notification_emails: domain.notification_emails }); 36 | } 37 | }, [domain]); 38 | 39 | const updateNotiEmails = (event:React.FormEvent) => { 40 | setDomainSettings({ ...domainSettings, notification_emails: event.currentTarget.value }); 41 | }; 42 | 43 | const updateDomain = () => { 44 | console.log('Domain: '); 45 | let error: DomainSettingsError | null = null; 46 | if (domainSettings.notification_emails) { 47 | const notification_emails = domainSettings.notification_emails.split(','); 48 | const invalidEmails = notification_emails.find((x) => /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(x) === false); 49 | console.log('invalidEmails: ', invalidEmails); 50 | if (invalidEmails) { 51 | error = { type: 'email', msg: 'Invalid Email' }; 52 | } 53 | } 54 | if (error && error.type) { 55 | console.log('Error!!!!!'); 56 | setSettingsError(error); 57 | setTimeout(() => { 58 | setSettingsError({ type: '', msg: '' }); 59 | }, 3000); 60 | } else if (domain) { 61 | updateMutate({ domainSettings, domain }); 62 | } 63 | }; 64 | 65 | return ( 66 |
67 | closeModal(false)} title={'Domain Settings'} width="[500px]"> 68 |
69 |
70 |

Notification Emails 71 | {settingsError.type === 'email' && {settingsError.msg}} 72 |

73 | 81 |
82 |
83 |
84 | 89 | 94 |
95 |
96 | {showRemoveDomain && domain && ( 97 | setShowRemoveDomain(false) } title={`Remove Domain ${domain.domain}`}> 98 |
99 |

Are you sure you want to remove this Domain? Removing this domain will remove all its keywords.

100 |
101 | 106 | 112 |
113 |
114 |
115 | )} 116 |
117 | ); 118 | }; 119 | 120 | export default DomainSettings; 121 | -------------------------------------------------------------------------------- /pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import Head from 'next/head'; 3 | import { useRouter } from 'next/router'; 4 | import { useState } from 'react'; 5 | import Icon from '../../components/common/Icon'; 6 | 7 | type LoginError = { 8 | type: string, 9 | msg: string, 10 | } 11 | 12 | const Login: NextPage = () => { 13 | const [error, setError] = useState(null); 14 | const [username, setUsername] = useState(''); 15 | const [password, setPassword] = useState(''); 16 | const router = useRouter(); 17 | 18 | const loginuser = async () => { 19 | let loginError: LoginError |null = null; 20 | if (!username || !password) { 21 | if (!username && !password) { 22 | loginError = { type: 'empty_username_password', msg: 'Please Insert Your App Username & Password to login.' }; 23 | } 24 | if (!username && password) { 25 | loginError = { type: 'empty_username', msg: 'Please Insert Your App Username' }; 26 | } 27 | if (!password && username) { 28 | loginError = { type: 'empty_password', msg: 'Please Insert Your App Password' }; 29 | } 30 | setError(loginError); 31 | setTimeout(() => { setError(null); }, 3000); 32 | } else { 33 | try { 34 | const header = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 35 | const fetchOpts = { method: 'POST', headers: header, body: JSON.stringify({ username, password }) }; 36 | const fetchRoute = `${window.location.origin}/api/login`; 37 | const res = await fetch(fetchRoute, fetchOpts).then((result) => result.json()); 38 | // console.log(res); 39 | if (!res.success) { 40 | let errorType = ''; 41 | if (res.error && res.error.toLowerCase().includes('username')) { 42 | errorType = 'incorrect_username'; 43 | } 44 | if (res.error && res.error.toLowerCase().includes('password')) { 45 | errorType = 'incorrect_password'; 46 | } 47 | setError({ type: errorType, msg: res.error }); 48 | setTimeout(() => { setError(null); }, 3000); 49 | } else { 50 | router.push('/'); 51 | } 52 | } catch (fetchError) { 53 | setError({ type: 'unknown', msg: 'Could not login, Ther Server is not responsive.' }); 54 | setTimeout(() => { setError(null); }, 3000); 55 | } 56 | } 57 | }; 58 | 59 | const labelStyle = 'mb-2 font-semibold inline-block text-sm text-gray-700'; 60 | // eslint-disable-next-line max-len 61 | const inputStyle = 'w-full p-2 border border-gray-200 rounded mb-3 focus:outline-none focus:border-blue-200'; 62 | const errorBorderStyle = 'border-red-400 focus:border-red-400'; 63 | return ( 64 |
65 | 66 | Login - SerpBear 67 | 68 |
69 |
70 |

71 | 72 | 73 | SerpBear 74 |

75 |
76 |
77 | 78 | setUsername(event.target.value)} 86 | /> 87 |
88 |
89 | 90 | setPassword(event.target.value)} 98 | /> 99 |
100 | 105 | {error && error.msg 106 | &&
108 | {error.msg} 109 |
110 | } 111 |
112 |
113 |
114 | 115 |
116 | ); 117 | }; 118 | 119 | export default Login; 120 | -------------------------------------------------------------------------------- /components/common/SelectField.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react'; 2 | import Icon from './Icon'; 3 | 4 | export type SelectionOption = { 5 | label:string, 6 | value: string 7 | } 8 | type SelectFieldProps = { 9 | defaultLabel: string, 10 | options: SelectionOption[], 11 | selected: string[], 12 | multiple?: boolean, 13 | updateField: Function, 14 | minWidth?: number, 15 | maxHeight?: number|string, 16 | rounded?: string, 17 | flags?: boolean, 18 | emptyMsg?: string 19 | } 20 | const SelectField = (props: SelectFieldProps) => { 21 | const { 22 | options, 23 | selected, 24 | defaultLabel = 'Select an Option', 25 | multiple = true, 26 | updateField, 27 | minWidth = 180, 28 | maxHeight = 96, 29 | rounded = 'rounded-3xl', 30 | flags = false, 31 | emptyMsg = '' } = props; 32 | 33 | const [showOptions, setShowOptions] = useState(false); 34 | const [filterInput, setFilterInput] = useState(''); 35 | const [filterdOptions, setFilterdOptions] = useState([]); 36 | 37 | const selectedLabels = useMemo(() => { 38 | return options.reduce((acc:string[], item:SelectionOption) :string[] => { 39 | return selected.includes(item.value) ? [...acc, item.label] : [...acc]; 40 | }, []); 41 | }, [selected, options]); 42 | 43 | const selectItem = (option:SelectionOption) => { 44 | let updatedSelect = [option.value]; 45 | if (multiple && Array.isArray(selected)) { 46 | if (selected.includes(option.value)) { 47 | updatedSelect = selected.filter((x) => x !== option.value); 48 | } else { 49 | updatedSelect = [...selected, option.value]; 50 | } 51 | } 52 | updateField(updatedSelect); 53 | if (!multiple) { setShowOptions(false); } 54 | }; 55 | 56 | const filterOptions = (event:React.FormEvent) => { 57 | setFilterInput(event.currentTarget.value); 58 | const filteredItems:SelectionOption[] = []; 59 | const userVal = event.currentTarget.value.toLowerCase(); 60 | options.forEach((option:SelectionOption) => { 61 | if (flags ? option.label.toLowerCase().startsWith(userVal) : option.label.toLowerCase().includes(userVal)) { 62 | filteredItems.push(option); 63 | } 64 | }); 65 | setFilterdOptions(filteredItems); 66 | }; 67 | 68 | return ( 69 |
70 |
setShowOptions(!showOptions)}> 74 | 75 | {selected.length > 0 ? (selectedLabels.slice(0, 2).join(', ')) : defaultLabel} 76 | 77 | {multiple && selected.length > 2 78 | && {(selected.length)}} 79 | 80 |
81 | {showOptions && ( 82 |
85 | {options.length > 20 && ( 86 |
87 | 94 |
95 | )} 96 |
    97 | {(options.length > 20 && filterdOptions.length > 0 && filterInput ? filterdOptions : options).map((opt) => { 98 | const itemActive = selected.includes(opt.value); 99 | return ( 100 |
  • selectItem(opt)} 105 | > 106 | {multiple && ( 107 | 110 | 111 | 112 | )} 113 | {flags && } 114 | {opt.label} 115 |
  • 116 | ); 117 | })} 118 |
119 | {emptyMsg && options.length === 0 &&

{emptyMsg}

} 120 |
121 | )} 122 |
123 | ); 124 | }; 125 | 126 | export default SelectField; 127 | -------------------------------------------------------------------------------- /pages/api/refresh.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Op } from 'sequelize'; 3 | import db from '../../database/database'; 4 | import Keyword from '../../database/models/keyword'; 5 | import refreshKeywords from '../../utils/refresh'; 6 | import { getAppSettings } from './settings'; 7 | import verifyUser from '../../utils/verifyUser'; 8 | import parseKeywords from '../../utils/parseKeywords'; 9 | import { removeFromRetryQueue, retryScrape } from '../../utils/scraper'; 10 | 11 | type KeywordsRefreshRes = { 12 | keywords?: KeywordType[] 13 | error?: string|null, 14 | } 15 | 16 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 17 | await db.sync(); 18 | const authorized = verifyUser(req, res); 19 | if (authorized !== 'authorized') { 20 | return res.status(401).json({ error: authorized }); 21 | } 22 | if (req.method === 'POST') { 23 | return refresTheKeywords(req, res); 24 | } 25 | return res.status(502).json({ error: 'Unrecognized Route.' }); 26 | } 27 | 28 | const refresTheKeywords = async (req: NextApiRequest, res: NextApiResponse) => { 29 | if (!req.query.id && typeof req.query.id !== 'string') { 30 | return res.status(400).json({ error: 'keyword ID is Required!' }); 31 | } 32 | if (req.query.id === 'all' && !req.query.domain) { 33 | return res.status(400).json({ error: 'When Refreshing all Keywords of a domian, the Domain name Must be provided.' }); 34 | } 35 | const keywordIDs = req.query.id !== 'all' && (req.query.id as string).split(',').map((item) => parseInt(item, 10)); 36 | const { domain } = req.query || {}; 37 | console.log('keywordIDs: ', keywordIDs); 38 | 39 | try { 40 | const settings = await getAppSettings(); 41 | if (!settings || (settings && settings.scraper_type === 'never')) { 42 | return res.status(400).json({ error: 'Scraper has not been set up yet.' }); 43 | } 44 | const query = req.query.id === 'all' && domain ? { domain } : { ID: { [Op.in]: keywordIDs } }; 45 | await Keyword.update({ updating: true }, { where: query }); 46 | const keywordQueries: Keyword[] = await Keyword.findAll({ where: query }); 47 | 48 | let keywords = []; 49 | 50 | // If Single Keyword wait for the scraping process, 51 | // else, Process the task in background. Do not wait. 52 | if (keywordIDs && keywordIDs.length === 0) { 53 | const refreshed: KeywordType[] = await refreshAndUpdateKeywords(keywordQueries, settings); 54 | keywords = refreshed; 55 | } else { 56 | refreshAndUpdateKeywords(keywordQueries, settings); 57 | keywords = parseKeywords(keywordQueries.map((el) => el.get({ plain: true }))); 58 | } 59 | 60 | return res.status(200).json({ keywords }); 61 | } catch (error) { 62 | console.log('ERROR refresThehKeywords: ', error); 63 | return res.status(400).json({ error: 'Error refreshing keywords!' }); 64 | } 65 | }; 66 | 67 | export const refreshAndUpdateKeywords = async (initKeywords:Keyword[], settings:SettingsType) => { 68 | const formattedKeywords = initKeywords.map((el) => el.get({ plain: true })); 69 | const refreshed: any = await refreshKeywords(formattedKeywords, settings); 70 | // const fetchKeywords = await refreshKeywords(initialKeywords.map( k=> k.keyword )); 71 | const updatedKeywords: KeywordType[] = []; 72 | 73 | for (const keywordRaw of initKeywords) { 74 | const keywordPrased = parseKeywords([keywordRaw.get({ plain: true })]); 75 | const keyword = keywordPrased[0]; 76 | const udpatedkeyword = refreshed.find((item:any) => item.ID && item.ID === keyword.ID); 77 | 78 | if (udpatedkeyword && keyword) { 79 | const newPos = udpatedkeyword.position; 80 | const newPosition = newPos !== false ? newPos : keyword.position; 81 | const { history } = keyword; 82 | const theDate = new Date(); 83 | history[`${theDate.getFullYear()}-${theDate.getMonth() + 1}-${theDate.getDate()}`] = newPosition; 84 | 85 | const updatedVal = { 86 | position: newPosition, 87 | updating: false, 88 | url: udpatedkeyword.url, 89 | lastResult: udpatedkeyword.result, 90 | history, 91 | lastUpdated: udpatedkeyword.error ? keyword.lastUpdated : theDate.toJSON(), 92 | lastUpdateError: udpatedkeyword.error 93 | ? JSON.stringify({ date: theDate.toJSON(), error: `${udpatedkeyword.error}`, scraper: settings.scraper_type }) 94 | : 'false', 95 | }; 96 | updatedKeywords.push({ ...keyword, ...{ ...updatedVal, lastUpdateError: JSON.parse(updatedVal.lastUpdateError) } }); 97 | 98 | // If failed, Add to Retry Queue Cron 99 | if (udpatedkeyword.error) { 100 | await retryScrape(keyword.ID); 101 | } else { 102 | await removeFromRetryQueue(keyword.ID); 103 | } 104 | 105 | // Update the Keyword Position in Database 106 | try { 107 | await keywordRaw.update({ 108 | ...updatedVal, 109 | lastResult: Array.isArray(udpatedkeyword.result) ? JSON.stringify(udpatedkeyword.result) : udpatedkeyword.result, 110 | history: JSON.stringify(history), 111 | }); 112 | console.log('[SUCCESS] Updating the Keyword: ', keyword.keyword); 113 | } catch (error) { 114 | console.log('[ERROR] Updating SERP for Keyword', keyword.keyword, error); 115 | } 116 | } 117 | } 118 | return updatedKeywords; 119 | }; 120 | -------------------------------------------------------------------------------- /components/keywords/AddKeywords.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Icon from '../common/Icon'; 3 | import Modal from '../common/Modal'; 4 | import SelectField from '../common/SelectField'; 5 | import countries from '../../utils/countries'; 6 | import { useAddKeywords } from '../../services/keywords'; 7 | 8 | type AddKeywordsProps = { 9 | keywords: KeywordType[], 10 | closeModal: Function, 11 | domain: string 12 | } 13 | 14 | type KeywordsInput = { 15 | keywords: string, 16 | device: string, 17 | country: string, 18 | domain: string, 19 | tags: string, 20 | } 21 | 22 | const AddKeywords = ({ closeModal, domain, keywords }: AddKeywordsProps) => { 23 | const [error, setError] = useState(''); 24 | const [newKeywordsData, setNewKeywordsData] = useState({ keywords: '', device: 'desktop', country: 'US', domain, tags: '' }); 25 | const { mutate: addMutate, isLoading: isAdding } = useAddKeywords(() => closeModal(false)); 26 | const deviceTabStyle = 'cursor-pointer px-3 py-2 rounded mr-2'; 27 | 28 | const addKeywords = () => { 29 | if (newKeywordsData.keywords) { 30 | const keywordsArray = [...new Set(newKeywordsData.keywords.split('\n').map((item) => item.trim()).filter((item) => !!item))]; 31 | const currentKeywords = keywords.map((k) => `${k.keyword}-${k.device}-${k.country}`); 32 | const keywordExist = keywordsArray.filter((k) => currentKeywords.includes(`${k}-${newKeywordsData.device}-${newKeywordsData.country}`)); 33 | if (keywordExist.length > 0) { 34 | setError(`Keywords ${keywordExist.join(',')} already Exist`); 35 | setTimeout(() => { setError(''); }, 3000); 36 | } else { 37 | addMutate({ ...newKeywordsData, keywords: keywordsArray.join('\n') }); 38 | } 39 | } else { 40 | setError('Please Insert a Keyword'); 41 | setTimeout(() => { setError(''); }, 3000); 42 | } 43 | }; 44 | 45 | return ( 46 | { closeModal(false); }} title={'Add New Keywords'} width="[420px]"> 47 |
48 |
49 |
50 | 56 |
57 | 58 |
59 |
60 | { return { label: countries[countryISO][0], value: countryISO }; })} 64 | defaultLabel='All Countries' 65 | updateField={(updated:string[]) => setNewKeywordsData({ ...newKeywordsData, country: updated[0] })} 66 | rounded='rounded' 67 | maxHeight={48} 68 | flags={true} 69 | /> 70 |
71 |
    72 |
  • setNewKeywordsData({ ...newKeywordsData, device: 'desktop' })} 75 | > Desktop
  • 76 |
  • setNewKeywordsData({ ...newKeywordsData, device: 'mobile' })} 79 | > Mobile
  • 80 |
81 |
82 | 83 |
84 | {/* TODO: Insert Existing Tags as Suggestions */} 85 | setNewKeywordsData({ ...newKeywordsData, tags: e.target.value })} 90 | /> 91 | 92 |
93 |
94 | {error &&
{error}
} 95 |
96 | 101 | 106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | export default AddKeywords; 113 | -------------------------------------------------------------------------------- /services/keywords.tsx: -------------------------------------------------------------------------------- 1 | import toast from 'react-hot-toast'; 2 | import { NextRouter } from 'next/router'; 3 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 4 | 5 | type KeywordsInput = { 6 | keywords: string, 7 | device: string, 8 | country: string, 9 | domain: string, 10 | tags: string, 11 | } 12 | 13 | export const fetchKeywords = async (router: NextRouter) => { 14 | if (!router.query.slug) { return []; } 15 | const res = await fetch(`${window.location.origin}/api/keywords?domain=${router.query.slug}`, { method: 'GET' }); 16 | return res.json(); 17 | }; 18 | 19 | export function useFetchKeywords(router: NextRouter, setKeywordSPollInterval:Function, keywordSPollInterval:undefined|number = undefined) { 20 | const { data: keywordsData, isLoading: keywordsLoading, isError } = useQuery( 21 | ['keywords', router.query.slug], 22 | () => fetchKeywords(router), 23 | { 24 | refetchInterval: keywordSPollInterval, 25 | onSuccess: (data) => { 26 | // If Keywords are Manually Refreshed check if the any of the keywords position are still being fetched 27 | // If yes, then refecth the keywords every 5 seconds until all the keywords position is updated by the server 28 | if (data.keywords && data.keywords.length > 0 && setKeywordSPollInterval) { 29 | const hasRefreshingKeyword = data.keywords.some((x:KeywordType) => x.updating); 30 | if (hasRefreshingKeyword) { 31 | setKeywordSPollInterval(5000); 32 | } else { 33 | if (keywordSPollInterval) { 34 | toast('Keywords Refreshed!', { icon: '✔️' }); 35 | } 36 | setKeywordSPollInterval(undefined); 37 | } 38 | } 39 | }, 40 | }, 41 | ); 42 | return { keywordsData, keywordsLoading, isError }; 43 | } 44 | 45 | export function useAddKeywords(onSuccess:Function) { 46 | const queryClient = useQueryClient(); 47 | return useMutation(async (newKeywords:KeywordsInput) => { 48 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 49 | const fetchOpts = { method: 'POST', headers, body: JSON.stringify(newKeywords) }; 50 | const res = await fetch(`${window.location.origin}/api/keywords`, fetchOpts); 51 | if (res.status >= 400 && res.status < 600) { 52 | throw new Error('Bad response from server'); 53 | } 54 | return res.json(); 55 | }, { 56 | onSuccess: async () => { 57 | console.log('Keywords Added!!!'); 58 | toast('Keywords Added Successfully!', { icon: '✔️' }); 59 | onSuccess(); 60 | queryClient.invalidateQueries(['keywords']); 61 | }, 62 | onError: () => { 63 | console.log('Error Adding New Keywords!!!'); 64 | toast('Error Adding New Keywords', { icon: '⚠️' }); 65 | }, 66 | }); 67 | } 68 | 69 | export function useDeleteKeywords(onSuccess:Function) { 70 | const queryClient = useQueryClient(); 71 | return useMutation(async (keywordIDs:number[]) => { 72 | const keywordIds = keywordIDs.join(','); 73 | const res = await fetch(`${window.location.origin}/api/keywords?id=${keywordIds}`, { method: 'DELETE' }); 74 | if (res.status >= 400 && res.status < 600) { 75 | throw new Error('Bad response from server'); 76 | } 77 | return res.json(); 78 | }, { 79 | onSuccess: async () => { 80 | console.log('Removed Keyword!!!'); 81 | onSuccess(); 82 | toast('Keywords Removed Successfully!', { icon: '✔️' }); 83 | queryClient.invalidateQueries(['keywords']); 84 | }, 85 | onError: () => { 86 | console.log('Error Removing Keyword!!!'); 87 | toast('Error Removing the Keywords', { icon: '⚠️' }); 88 | }, 89 | }); 90 | } 91 | 92 | export function useFavKeywords(onSuccess:Function) { 93 | const queryClient = useQueryClient(); 94 | return useMutation(async ({ keywordID, sticky }:{keywordID:number, sticky:boolean}) => { 95 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 96 | const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ sticky }) }; 97 | const res = await fetch(`${window.location.origin}/api/keywords?id=${keywordID}`, fetchOpts); 98 | if (res.status >= 400 && res.status < 600) { 99 | throw new Error('Bad response from server'); 100 | } 101 | return res.json(); 102 | }, { 103 | onSuccess: async (data) => { 104 | onSuccess(); 105 | const isSticky = data.keywords[0] && data.keywords[0].sticky; 106 | toast(isSticky ? 'Keywords Made Favorite!' : 'Keywords Unfavorited!', { icon: '✔️' }); 107 | queryClient.invalidateQueries(['keywords']); 108 | }, 109 | onError: () => { 110 | console.log('Error Changing Favorite Status!!!'); 111 | toast('Error Changing Favorite Status.', { icon: '⚠️' }); 112 | }, 113 | }); 114 | } 115 | 116 | export function useUpdateKeywordTags(onSuccess:Function) { 117 | const queryClient = useQueryClient(); 118 | return useMutation(async ({ tags }:{tags:{ [ID:number]: string[] }}) => { 119 | const keywordIds = Object.keys(tags).join(','); 120 | const headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json' }); 121 | const fetchOpts = { method: 'PUT', headers, body: JSON.stringify({ tags }) }; 122 | const res = await fetch(`${window.location.origin}/api/keywords?id=${keywordIds}`, fetchOpts); 123 | if (res.status >= 400 && res.status < 600) { 124 | throw new Error('Bad response from server'); 125 | } 126 | return res.json(); 127 | }, { 128 | onSuccess: async () => { 129 | onSuccess(); 130 | toast('Keyword Tags Updated!', { icon: '✔️' }); 131 | queryClient.invalidateQueries(['keywords']); 132 | }, 133 | onError: () => { 134 | console.log('Error Updating Keyword Tags!!!'); 135 | toast('Error Updating Keyword Tags.', { icon: '⚠️' }); 136 | }, 137 | }); 138 | } 139 | 140 | export function useRefreshKeywords(onSuccess:Function) { 141 | const queryClient = useQueryClient(); 142 | return useMutation(async ({ ids = [], domain = '' } : {ids?: number[], domain?: string}) => { 143 | const keywordIds = ids.join(','); 144 | console.log(keywordIds); 145 | const query = ids.length === 0 && domain ? `?id=all&domain=${domain}` : `?id=${keywordIds}`; 146 | const res = await fetch(`${window.location.origin}/api/refresh${query}`, { method: 'POST' }); 147 | if (res.status >= 400 && res.status < 600) { 148 | throw new Error('Bad response from server'); 149 | } 150 | return res.json(); 151 | }, { 152 | onSuccess: async () => { 153 | console.log('Keywords Added to Refresh Queue!!!'); 154 | onSuccess(); 155 | toast('Keywords Added to Refresh Queue', { icon: '🔄' }); 156 | queryClient.invalidateQueries(['keywords']); 157 | }, 158 | onError: () => { 159 | console.log('Error Refreshing Keywords!!!'); 160 | toast('Error Refreshing Keywords.', { icon: '⚠️' }); 161 | }, 162 | }); 163 | } 164 | -------------------------------------------------------------------------------- /pages/api/keywords.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import { Op } from 'sequelize'; 3 | import db from '../../database/database'; 4 | import Keyword from '../../database/models/keyword'; 5 | import { refreshAndUpdateKeywords } from './refresh'; 6 | import { getAppSettings } from './settings'; 7 | import verifyUser from '../../utils/verifyUser'; 8 | import parseKeywords from '../../utils/parseKeywords'; 9 | 10 | type KeywordsGetResponse = { 11 | keywords?: KeywordType[], 12 | error?: string|null, 13 | } 14 | 15 | type KeywordsDeleteRes = { 16 | domainRemoved?: number, 17 | keywordsRemoved?: number, 18 | error?: string|null, 19 | } 20 | 21 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 22 | await db.sync(); 23 | const authorized = verifyUser(req, res); 24 | if (authorized !== 'authorized') { 25 | return res.status(401).json({ error: authorized }); 26 | } 27 | 28 | if (req.method === 'GET') { 29 | return getKeywords(req, res); 30 | } 31 | if (req.method === 'POST') { 32 | return addKeywords(req, res); 33 | } 34 | if (req.method === 'DELETE') { 35 | return deleteKeywords(req, res); 36 | } 37 | if (req.method === 'PUT') { 38 | return updateKeywords(req, res); 39 | } 40 | return res.status(502).json({ error: 'Unrecognized Route.' }); 41 | } 42 | 43 | const getKeywords = async (req: NextApiRequest, res: NextApiResponse) => { 44 | if (!req.query.domain && typeof req.query.domain !== 'string') { 45 | return res.status(400).json({ error: 'Domain is Required!' }); 46 | } 47 | const domain = (req.query.domain as string).replaceAll('-', '.').replaceAll('_', '-'); 48 | 49 | try { 50 | const allKeywords:Keyword[] = await Keyword.findAll({ where: { domain } }); 51 | const keywords: KeywordType[] = parseKeywords(allKeywords.map((e) => e.get({ plain: true }))); 52 | const slimKeywords = keywords.map((keyword) => { 53 | const historyArray = Object.keys(keyword.history).map((dateKey:string) => ({ 54 | date: new Date(dateKey).getTime(), 55 | dateRaw: dateKey, 56 | position: keyword.history[dateKey], 57 | })); 58 | const historySorted = historyArray.sort((a, b) => a.date - b.date); 59 | const lastWeekHistory :KeywordHistory = {}; 60 | historySorted.slice(-7).forEach((x:any) => { lastWeekHistory[x.dateRaw] = x.position; }); 61 | return { ...keyword, lastResult: [], history: lastWeekHistory }; 62 | }); 63 | console.log('getKeywords: ', keywords.length); 64 | return res.status(200).json({ keywords: slimKeywords }); 65 | } catch (error) { 66 | console.log(error); 67 | return res.status(400).json({ error: 'Error Loading Keywords for this Domain.' }); 68 | } 69 | }; 70 | 71 | const addKeywords = async (req: NextApiRequest, res: NextApiResponse) => { 72 | const { keywords, device, country, domain, tags } = req.body; 73 | if (keywords && device && country) { 74 | const keywordsArray = keywords.replaceAll('\n', ',').split(',').map((item:string) => item.trim()); 75 | const tagsArray = tags ? tags.split(',').map((item:string) => item.trim()) : []; 76 | const keywordsToAdd: any = []; // QuickFIX for bug: https://github.com/sequelize/sequelize-typescript/issues/936 77 | 78 | keywordsArray.forEach((keyword: string) => { 79 | const newKeyword = { 80 | keyword, 81 | device, 82 | domain, 83 | country, 84 | position: 0, 85 | updating: true, 86 | history: JSON.stringify({}), 87 | url: '', 88 | tags: JSON.stringify(tagsArray), 89 | sticky: false, 90 | lastUpdated: new Date().toJSON(), 91 | added: new Date().toJSON(), 92 | }; 93 | keywordsToAdd.push(newKeyword); 94 | }); 95 | 96 | try { 97 | const newKeywords:Keyword[] = await Keyword.bulkCreate(keywordsToAdd); 98 | const formattedkeywords = newKeywords.map((el) => el.get({ plain: true })); 99 | const keywordsParsed: KeywordType[] = parseKeywords(formattedkeywords); 100 | const settings = await getAppSettings(); 101 | refreshAndUpdateKeywords(newKeywords, settings); // Queue the SERP Scraping Process 102 | return res.status(201).json({ keywords: keywordsParsed }); 103 | } catch (error) { 104 | return res.status(400).json({ error: 'Could Not Add New Keyword!' }); 105 | } 106 | } else { 107 | return res.status(400).json({ error: 'Necessary Keyword Data Missing' }); 108 | } 109 | }; 110 | 111 | const deleteKeywords = async (req: NextApiRequest, res: NextApiResponse) => { 112 | if (!req.query.id && typeof req.query.id !== 'string') { 113 | return res.status(400).json({ error: 'keyword ID is Required!' }); 114 | } 115 | console.log('req.query.id: ', req.query.id); 116 | 117 | try { 118 | const keywordsToRemove = (req.query.id as string).split(',').map((item) => parseInt(item, 10)); 119 | const removeQuery = { where: { ID: { [Op.in]: keywordsToRemove } } }; 120 | const removedKeywordCount: number = await Keyword.destroy(removeQuery); 121 | return res.status(200).json({ keywordsRemoved: removedKeywordCount }); 122 | } catch (error) { 123 | return res.status(400).json({ error: 'Could Not Remove Keyword!' }); 124 | } 125 | }; 126 | 127 | const updateKeywords = async (req: NextApiRequest, res: NextApiResponse) => { 128 | if (!req.query.id && typeof req.query.id !== 'string') { 129 | return res.status(400).json({ error: 'keyword ID is Required!' }); 130 | } 131 | if (req.body.sticky === undefined && !req.body.tags === undefined) { 132 | return res.status(400).json({ error: 'keyword Payload Missing!' }); 133 | } 134 | const keywordIDs = (req.query.id as string).split(',').map((item) => parseInt(item, 10)); 135 | const { sticky, tags } = req.body; 136 | 137 | try { 138 | let keywords: KeywordType[] = []; 139 | if (sticky !== undefined) { 140 | await Keyword.update({ sticky }, { where: { ID: { [Op.in]: keywordIDs } } }); 141 | const updateQuery = { where: { ID: { [Op.in]: keywordIDs } } }; 142 | const updatedKeywords:Keyword[] = await Keyword.findAll(updateQuery); 143 | const formattedKeywords = updatedKeywords.map((el) => el.get({ plain: true })); 144 | keywords = parseKeywords(formattedKeywords); 145 | return res.status(200).json({ keywords }); 146 | } 147 | if (tags) { 148 | const tagsKeywordIDs = Object.keys(tags); 149 | for (const keywordID of tagsKeywordIDs) { 150 | const response = await Keyword.findOne({ where: { ID: keywordID } }); 151 | if (response) { 152 | await response.update({ tags: JSON.stringify(tags[keywordID]) }); 153 | } 154 | } 155 | return res.status(200).json({ keywords }); 156 | } 157 | return res.status(400).json({ error: 'Invalid Payload!' }); 158 | } catch (error) { 159 | console.log('ERROR updateKeywords: ', error); 160 | return res.status(200).json({ error: 'Error Updating keywords!' }); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /components/keywords/KeywordFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from 'react'; 2 | import Icon from '../common/Icon'; 3 | import SelectField, { SelectionOption } from '../common/SelectField'; 4 | import countries from '../../utils/countries'; 5 | 6 | type KeywordFilterProps = { 7 | device: string, 8 | allTags: string[], 9 | setDevice: Function, 10 | filterParams: KeywordFilters, 11 | filterKeywords: Function, 12 | keywords: KeywordType[], 13 | updateSort: Function, 14 | sortBy: string 15 | } 16 | 17 | type KeywordCountState = { 18 | desktop: number, 19 | mobile: number 20 | } 21 | 22 | const KeywordFilters = (props: KeywordFilterProps) => { 23 | const { 24 | device, 25 | setDevice, 26 | filterKeywords, 27 | allTags = [], 28 | keywords, 29 | updateSort, 30 | sortBy, 31 | filterParams } = props; 32 | const [keywordCounts, setKeywordCounts] = useState({ desktop: 0, mobile: 0 }); 33 | const [sortOptions, showSortOptions] = useState(false); 34 | const [filterOptions, showFilterOptions] = useState(false); 35 | 36 | useEffect(() => { 37 | const keyWordCount = { desktop: 0, mobile: 0 }; 38 | keywords.forEach((k) => { 39 | if (k.device === 'desktop') { 40 | keyWordCount.desktop += 1; 41 | } else { 42 | keyWordCount.mobile += 1; 43 | } 44 | }); 45 | setKeywordCounts(keyWordCount); 46 | }, [keywords]); 47 | 48 | const filterCountry = (cntrs:string[]) => filterKeywords({ ...filterParams, countries: cntrs }); 49 | 50 | const filterTags = (tags:string[]) => filterKeywords({ ...filterParams, tags }); 51 | 52 | const searchKeywords = (event:React.FormEvent) => { 53 | const filtered = filterKeywords({ ...filterParams, search: event.currentTarget.value }); 54 | return filtered; 55 | }; 56 | 57 | const countryOptions = useMemo(() => { 58 | const optionObject = Object.keys(countries).map((countryISO:string) => ({ 59 | label: countries[countryISO][0], 60 | value: countryISO, 61 | })); 62 | return optionObject; 63 | }, []); 64 | 65 | const sortOptionChoices: SelectionOption[] = [ 66 | { value: 'pos_asc', label: 'Top Position' }, 67 | { value: 'pos_desc', label: 'Lowest Position' }, 68 | { value: 'date_asc', label: 'Most Recent (Default)' }, 69 | { value: 'date_desc', label: 'Oldest' }, 70 | { value: 'alpha_asc', label: 'Alphabetically(A-Z)' }, 71 | { value: 'alpha_desc', label: 'Alphabetically(Z-A)' }, 72 | ]; 73 | const sortItemStyle = (sortType:string) => { 74 | return `cursor-pointer py-2 px-3 hover:bg-[#FCFCFF] ${sortBy === sortType ? 'bg-indigo-50 text-indigo-600 hover:bg-indigo-50' : ''}`; 75 | }; 76 | const deviceTabStyle = 'select-none cursor-pointer px-3 py-2 rounded-3xl mr-2'; 77 | const deviceTabCountStyle = 'px-2 py-0 rounded-3xl bg-[#DEE1FC] text-[0.7rem] font-bold ml-1'; 78 | const mobileFilterOptionsStyle = 'visible mt-8 border absolute min-w-[0] rounded-lg max-h-96 bg-white z-50 w-52 right-2 p-4'; 79 | 80 | return ( 81 |
82 |
83 |
    84 |
  • setDevice('desktop')}> 88 | 89 | Desktop 90 | {keywordCounts.desktop} 91 |
  • 92 |
  • setDevice('mobile')}> 96 | 97 | Mobile 98 | {keywordCounts.mobile} 99 |
  • 100 |
101 |
102 |
103 |
104 | 111 |
112 |
113 |
114 | filterCountry(updated)} 119 | flags={true} 120 | /> 121 |
122 |
123 | ({ label: tag, value: tag }))} 126 | defaultLabel='All Tags' 127 | updateField={(updated:string[]) => filterTags(updated)} 128 | emptyMsg="No Tags Found for this Domain" 129 | /> 130 |
131 |
132 | 140 |
141 |
142 |
143 | 150 | {sortOptions && ( 151 |
    154 | {sortOptionChoices.map((sortOption) => { 155 | return
  • { updateSort(sortOption.value); showSortOptions(false); }}> 159 | {sortOption.label} 160 |
  • ; 161 | })} 162 |
163 | )} 164 |
165 |
166 |
167 | ); 168 | }; 169 | 170 | export default KeywordFilters; 171 | -------------------------------------------------------------------------------- /components/keywords/KeywordDetails.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from 'react'; 2 | import dayjs from 'dayjs'; 3 | import Icon from '../common/Icon'; 4 | import countries from '../../utils/countries'; 5 | import Chart from '../common/Chart'; 6 | import SelectField from '../common/SelectField'; 7 | import { generateTheChartData } from '../common/generateChartData'; 8 | 9 | type KeywordDetailsProps = { 10 | keyword: KeywordType, 11 | closeDetails: Function 12 | } 13 | 14 | const KeywordDetails = ({ keyword, closeDetails }:KeywordDetailsProps) => { 15 | const updatedDate = new Date(keyword.lastUpdated); 16 | const [keywordHistory, setKeywordHistory] = useState(keyword.history); 17 | const [keywordSearchResult, setKeywordSearchResult] = useState([]); 18 | const [chartTime, setChartTime] = useState('30'); 19 | const searchResultContainer = useRef(null); 20 | const searchResultFound = useRef(null); 21 | const dateOptions = [ 22 | { label: 'Last 7 Days', value: '7' }, 23 | { label: 'Last 30 Days', value: '30' }, 24 | { label: 'Last 90 Days', value: '90' }, 25 | { label: '1 Year', value: '360' }, 26 | { label: 'All Time', value: 'all' }, 27 | ]; 28 | 29 | useEffect(() => { 30 | const fetchFullKeyword = async () => { 31 | try { 32 | const fetchURL = `${window.location.origin}/api/keyword?id=${keyword.ID}`; 33 | const res = await fetch(fetchURL, { method: 'GET' }).then((result) => result.json()); 34 | if (res.keyword) { 35 | console.log(res.keyword, new Date().getTime()); 36 | setKeywordHistory(res.keyword.history || []); 37 | setKeywordSearchResult(res.keyword.lastResult || []); 38 | } 39 | } catch (error) { 40 | console.log(error); 41 | } 42 | }; 43 | if (keyword.lastResult.length === 0) { 44 | fetchFullKeyword(); 45 | } 46 | }, [keyword]); 47 | 48 | useEffect(() => { 49 | const closeModalonEsc = (event:KeyboardEvent) => { 50 | if (event.key === 'Escape') { 51 | console.log(event.key); 52 | closeDetails(); 53 | } 54 | }; 55 | window.addEventListener('keydown', closeModalonEsc, false); 56 | return () => { 57 | window.removeEventListener('keydown', closeModalonEsc, false); 58 | }; 59 | }, [closeDetails]); 60 | 61 | useEffect(() => { 62 | if (keyword.position < 100 && keyword.position > 0 && searchResultFound?.current) { 63 | searchResultFound.current.scrollIntoView({ 64 | behavior: 'smooth', 65 | block: 'center', 66 | inline: 'start', 67 | }); 68 | } 69 | }, [keywordSearchResult, keyword.position]); 70 | 71 | const chartData = useMemo(() => { 72 | return generateTheChartData(keywordHistory, chartTime); 73 | }, [keywordHistory, chartTime]); 74 | 75 | const closeOnBGClick = (e:React.SyntheticEvent) => { 76 | e.stopPropagation(); 77 | e.nativeEvent.stopImmediatePropagation(); 78 | if (e.target === e.currentTarget) { closeDetails(); } 79 | }; 80 | 81 | return ( 82 |
83 |
84 |
85 |

86 | {keyword.keyword} 88 | {keyword.position} 89 |

90 | 95 |
96 |
97 | 98 |
99 |
100 |

SERP History

101 |
102 | setChartTime(updatedTime[0])} 107 | multiple={false} 108 | rounded={'rounded'} 109 | /> 110 |
111 |
112 |
113 | 114 |
115 |
116 |
117 |
118 |

Google Search Result 119 | 123 | 124 | 125 |

126 | {dayjs(updatedDate).format('MMMM D, YYYY')} 127 |
128 |
129 | {keywordSearchResult && Array.isArray(keywordSearchResult) && keywordSearchResult.length > 0 && ( 130 | keywordSearchResult.map((item, index) => { 131 | const { position } = keyword; 132 | const domainExist = position < 100 && index === (position - 1); 133 | return ( 134 |
139 |

140 | {`${index + 1}. ${item.title}`} 141 |

142 | {/*

{item.description}

*/} 143 | {item.url} 144 |
145 | ); 146 | }) 147 | )} 148 |
149 |
150 |
151 |
152 |
153 | ); 154 | }; 155 | 156 | export default KeywordDetails; 157 | -------------------------------------------------------------------------------- /components/keywords/Keyword.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import TimeAgo from 'react-timeago'; 3 | import dayjs from 'dayjs'; 4 | import Icon from '../common/Icon'; 5 | import countries from '../../utils/countries'; 6 | import ChartSlim from '../common/ChartSlim'; 7 | import { generateTheChartData } from '../common/generateChartData'; 8 | 9 | type KeywordProps = { 10 | keywordData: KeywordType, 11 | selected: boolean, 12 | refreshkeyword: Function, 13 | favoriteKeyword: Function, 14 | removeKeyword: Function, 15 | selectKeyword: Function, 16 | manageTags: Function, 17 | showKeywordDetails: Function, 18 | lastItem?:boolean 19 | } 20 | 21 | const Keyword = (props: KeywordProps) => { 22 | const { keywordData, refreshkeyword, favoriteKeyword, removeKeyword, selectKeyword, selected, showKeywordDetails, manageTags, lastItem } = props; 23 | const { 24 | keyword, domain, ID, position, url = '', lastUpdated, country, sticky, history = {}, updating = false, lastUpdateError = false, 25 | } = keywordData; 26 | const [showOptions, setShowOptions] = useState(false); 27 | const [showPositionError, setPositionError] = useState(false); 28 | const turncatedURL = useMemo(() => { 29 | return url.replace(`https://${domain}`, '').replace(`https://www.${domain}`, '').replace(`http://${domain}`, ''); 30 | }, [url, domain]); 31 | 32 | const chartData = useMemo(() => { 33 | return generateTheChartData(history, '7'); 34 | }, [history]); 35 | 36 | const positionChange = useMemo(() => { 37 | let status = 0; 38 | if (Object.keys(history).length >= 2) { 39 | const historyArray = Object.keys(history).map((dateKey:string) => { 40 | return { date: new Date(dateKey).getTime(), dateRaw: dateKey, position: history[dateKey] }; 41 | }); 42 | const historySorted = historyArray.sort((a, b) => a.date - b.date); 43 | const previousPos = historySorted[historySorted.length - 2].position; 44 | status = previousPos === 0 ? position : previousPos - position; 45 | } 46 | return status; 47 | }, [history, position]); 48 | 49 | const optionsButtonStyle = 'block px-2 py-2 cursor-pointer hover:bg-indigo-50 hover:text-blue-700'; 50 | 51 | const renderPosition = () => { 52 | if (position === 0) { 53 | return {'>100'}; 54 | } 55 | if (updating) { 56 | return ; 57 | } 58 | return position; 59 | }; 60 | 61 | return ( 62 |
66 |
67 | 74 | showKeywordDetails()}> 77 | {keyword} 78 | 79 | {sticky && } 80 | {lastUpdateError && lastUpdateError.date 81 | && 84 | } 85 |
86 |
89 | {renderPosition()} 90 | {!updating && positionChange > 0 && ▲ {positionChange}} 91 | {!updating && positionChange < 0 && ▼ {positionChange}} 92 |
93 | {chartData.labels.length > 0 && ( 94 |
95 | 96 |
97 | )} 98 |
101 | {turncatedURL || '-'}
102 |
104 | 105 | 106 |
107 | 136 | {lastUpdateError && lastUpdateError.date && showPositionError && ( 137 |
138 | Error Updating Keyword position (Tried ) 141 | setPositionError(false)}> 142 | 143 | 144 |
145 | {lastUpdateError.scraper && {lastUpdateError.scraper}: }{lastUpdateError.error} 146 |
147 |
148 | )} 149 |
150 | ); 151 | }; 152 | 153 | export default Keyword; 154 | -------------------------------------------------------------------------------- /components/keywords/KeywordsTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import { Toaster } from 'react-hot-toast'; 3 | import { CSSTransition } from 'react-transition-group'; 4 | import AddKeywords from './AddKeywords'; 5 | import { filterKeywords, keywordsByDevice, sortKeywords } from '../../utils/sortFilter'; 6 | import Icon from '../common/Icon'; 7 | import Keyword from './Keyword'; 8 | import KeywordDetails from './KeywordDetails'; 9 | import KeywordFilters from './KeywordFilter'; 10 | import Modal from '../common/Modal'; 11 | import { useDeleteKeywords, useFavKeywords, useRefreshKeywords } from '../../services/keywords'; 12 | import KeywordTagManager from './KeywordTagManager'; 13 | 14 | type KeywordsTableProps = { 15 | domain: Domain | null, 16 | keywords: KeywordType[], 17 | isLoading: boolean, 18 | showAddModal: boolean, 19 | setShowAddModal: Function 20 | } 21 | 22 | const KeywordsTable = ({ domain, keywords = [], isLoading = true, showAddModal = false, setShowAddModal }: KeywordsTableProps) => { 23 | const [device, setDevice] = useState('desktop'); 24 | const [selectedKeywords, setSelectedKeywords] = useState([]); 25 | const [showKeyDetails, setShowKeyDetails] = useState(null); 26 | const [showRemoveModal, setShowRemoveModal] = useState(false); 27 | const [showTagManager, setShowTagManager] = useState(null); 28 | const [filterParams, setFilterParams] = useState({ countries: [], tags: [], search: '' }); 29 | const [sortBy, setSortBy] = useState('date_asc'); 30 | const { mutate: deleteMutate } = useDeleteKeywords(() => {}); 31 | const { mutate: favoriteMutate } = useFavKeywords(() => {}); 32 | const { mutate: refreshMutate } = useRefreshKeywords(() => {}); 33 | 34 | const processedKeywords: {[key:string] : KeywordType[]} = useMemo(() => { 35 | const procKeywords = keywords.filter((x) => x.device === device); 36 | const filteredKeywords = filterKeywords(procKeywords, filterParams); 37 | const sortedKeywords = sortKeywords(filteredKeywords, sortBy); 38 | return keywordsByDevice(sortedKeywords, device); 39 | }, [keywords, device, sortBy, filterParams]); 40 | 41 | const allDomainTags: string[] = useMemo(() => { 42 | const allTags = keywords.reduce((acc: string[], keyword) => [...acc, ...keyword.tags], []); 43 | return [...new Set(allTags)]; 44 | }, [keywords]); 45 | 46 | const selectKeyword = (keywordID: number) => { 47 | console.log('Select Keyword: ', keywordID); 48 | let updatedSelectd = [...selectedKeywords, keywordID]; 49 | if (selectedKeywords.includes(keywordID)) { 50 | updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID); 51 | } 52 | setSelectedKeywords(updatedSelectd); 53 | }; 54 | 55 | const selectedAllItems = selectedKeywords.length === processedKeywords[device].length; 56 | 57 | return ( 58 |
59 |
60 | {selectedKeywords.length > 0 && ( 61 | 80 | )} 81 | {selectedKeywords.length === 0 && ( 82 | setFilterParams(params)} 86 | updateSort={(sorted:string) => setSortBy(sorted)} 87 | sortBy={sortBy} 88 | keywords={keywords} 89 | device={device} 90 | setDevice={setDevice} 91 | /> 92 | )} 93 |
94 |
95 | 114 |
115 | {processedKeywords[device] && processedKeywords[device].length > 0 116 | && processedKeywords[device].map((keyword, index) => refreshMutate({ ids: [keyword.ID] })} 122 | favoriteKeyword={favoriteMutate} 123 | manageTags={() => setShowTagManager(keyword.ID)} 124 | removeKeyword={() => { setSelectedKeywords([keyword.ID]); setShowRemoveModal(true); }} 125 | showKeywordDetails={() => setShowKeyDetails(keyword)} 126 | lastItem={index === (processedKeywords[device].length - 1)} 127 | />)} 128 | {!isLoading && processedKeywords[device].length === 0 && ( 129 |

No Keywords Added for this Device Type.

130 | )} 131 | {isLoading && ( 132 |

Loading Keywords...

133 | )} 134 |
135 |
136 |
137 |
138 | {showKeyDetails && showKeyDetails.ID && ( 139 | setShowKeyDetails(null)} /> 140 | )} 141 | {showRemoveModal && selectedKeywords.length > 0 && ( 142 | { setSelectedKeywords([]); setShowRemoveModal(false); }} title={'Remove Keywords'}> 143 |
144 |

Are you sure you want to remove {selectedKeywords.length > 1 ? 'these' : 'this'} Keyword?

145 |
146 | 151 | 156 |
157 |
158 |
159 | )} 160 | 161 | setShowAddModal(false)} 165 | /> 166 | 167 | {showTagManager && ( 168 | k.ID === showTagManager)} 171 | closeModal={() => setShowTagManager(null)} 172 | /> 173 | )} 174 | 175 |
176 | ); 177 | }; 178 | 179 | export default KeywordsTable; 180 | -------------------------------------------------------------------------------- /utils/countries.ts: -------------------------------------------------------------------------------- 1 | const countries: countryData = { 2 | AD: ['Andorra', 'Andorra la Vella', 'ca'], 3 | AE: ['United Arab Emirates', 'Abu Dhabi', 'ar'], 4 | AF: ['Afghanistan', 'Kabul', 'ps'], 5 | AG: ['Antigua and Barbuda', "Saint John's", 'en'], 6 | AI: ['Anguilla', 'The Valley', 'en'], 7 | AL: ['Albania', 'Tirana', 'sq'], 8 | AM: ['Armenia', 'Yerevan', 'hy'], 9 | AO: ['Angola', 'Luanda', 'pt'], 10 | AQ: ['Antarctica', '', ''], 11 | AR: ['Argentina', 'Buenos Aires', 'es'], 12 | AS: ['American Samoa', 'Pago Pago', 'en'], 13 | AT: ['Austria', 'Vienna', 'de'], 14 | AU: ['Australia', 'Canberra', 'en'], 15 | AW: ['Aruba', 'Oranjestad', 'nl'], 16 | AX: ['Åland', 'Mariehamn', 'sv'], 17 | AZ: ['Azerbaijan', 'Baku', 'az'], 18 | BA: ['Bosnia and Herzegovina', 'Sarajevo', 'bs'], 19 | BB: ['Barbados', 'Bridgetown', 'en'], 20 | BD: ['Bangladesh', 'Dhaka', 'bn'], 21 | BE: ['Belgium', 'Brussels', 'nl'], 22 | BF: ['Burkina Faso', 'Ouagadougou', 'fr'], 23 | BG: ['Bulgaria', 'Sofia', 'bg'], 24 | BH: ['Bahrain', 'Manama', 'ar'], 25 | BI: ['Burundi', 'Bujumbura', 'fr'], 26 | BJ: ['Benin', 'Porto-Novo', 'fr'], 27 | BL: ['Saint Barthélemy', 'Gustavia', 'fr'], 28 | BM: ['Bermuda', 'Hamilton', 'en'], 29 | BN: ['Brunei', 'Bandar Seri Begawan', 'ms'], 30 | BO: ['Bolivia', 'Sucre', 'es'], 31 | BQ: ['Bonaire', 'Kralendijk', 'nl'], 32 | BR: ['Brazil', 'Brasília', 'pt'], 33 | BS: ['Bahamas', 'Nassau', 'en'], 34 | BT: ['Bhutan', 'Thimphu', 'dz'], 35 | BV: ['Bouvet Island', '', 'no'], 36 | BW: ['Botswana', 'Gaborone', 'en'], 37 | BY: ['Belarus', 'Minsk', 'be'], 38 | BZ: ['Belize', 'Belmopan', 'en'], 39 | CA: ['Canada', 'Ottawa', 'en'], 40 | CC: ['Cocos [Keeling] Islands', 'West Island', 'en'], 41 | CD: ['Democratic Republic of the Congo', 'Kinshasa', 'fr'], 42 | CF: ['Central African Republic', 'Bangui', 'fr'], 43 | CG: ['Republic of the Congo', 'Brazzaville', 'fr'], 44 | CH: ['Switzerland', 'Bern', 'de'], 45 | CI: ['Ivory Coast', 'Yamoussoukro', 'fr'], 46 | CK: ['Cook Islands', 'Avarua', 'en'], 47 | CL: ['Chile', 'Santiago', 'es'], 48 | CM: ['Cameroon', 'Yaoundé', 'en'], 49 | CN: ['China', 'Beijing', 'zh'], 50 | CO: ['Colombia', 'Bogotá', 'es'], 51 | CR: ['Costa Rica', 'San José', 'es'], 52 | CU: ['Cuba', 'Havana', 'es'], 53 | CV: ['Cape Verde', 'Praia', 'pt'], 54 | CW: ['Curacao', 'Willemstad', 'nl'], 55 | CX: ['Christmas Island', 'Flying Fish Cove', 'en'], 56 | CY: ['Cyprus', 'Nicosia', 'el'], 57 | CZ: ['Czech Republic', 'Prague', 'cs'], 58 | DE: ['Germany', 'Berlin', 'de'], 59 | DJ: ['Djibouti', 'Djibouti', 'fr'], 60 | DK: ['Denmark', 'Copenhagen', 'da'], 61 | DM: ['Dominica', 'Roseau', 'en'], 62 | DO: ['Dominican Republic', 'Santo Domingo', 'es'], 63 | DZ: ['Algeria', 'Algiers', 'ar'], 64 | EC: ['Ecuador', 'Quito', 'es'], 65 | EE: ['Estonia', 'Tallinn', 'et'], 66 | EG: ['Egypt', 'Cairo', 'ar'], 67 | EH: ['Western Sahara', 'El Aaiún', 'es'], 68 | ER: ['Eritrea', 'Asmara', 'ti'], 69 | ES: ['Spain', 'Madrid', 'es'], 70 | ET: ['Ethiopia', 'Addis Ababa', 'am'], 71 | FI: ['Finland', 'Helsinki', 'fi'], 72 | FJ: ['Fiji', 'Suva', 'en'], 73 | FK: ['Falkland Islands', 'Stanley', 'en'], 74 | FM: ['Micronesia', 'Palikir', 'en'], 75 | FO: ['Faroe Islands', 'Tórshavn', 'fo'], 76 | FR: ['France', 'Paris', 'fr'], 77 | GA: ['Gabon', 'Libreville', 'fr'], 78 | GB: ['United Kingdom', 'London', 'en'], 79 | GD: ['Grenada', "St. George's", 'en'], 80 | GE: ['Georgia', 'Tbilisi', 'ka'], 81 | GF: ['French Guiana', 'Cayenne', 'fr'], 82 | GG: ['Guernsey', 'St. Peter Port', 'en'], 83 | GH: ['Ghana', 'Accra', 'en'], 84 | GI: ['Gibraltar', 'Gibraltar', 'en'], 85 | GL: ['Greenland', 'Nuuk', 'kl'], 86 | GM: ['Gambia', 'Banjul', 'en'], 87 | GN: ['Guinea', 'Conakry', 'fr'], 88 | GP: ['Guadeloupe', 'Basse-Terre', 'fr'], 89 | GQ: ['Equatorial Guinea', 'Malabo', 'es'], 90 | GR: ['Greece', 'Athens', 'el'], 91 | GS: [ 92 | 'South Georgia and the South Sandwich Islands', 93 | 'King Edward Point', 94 | 'en', 95 | ], 96 | GT: ['Guatemala', 'Guatemala City', 'es'], 97 | GU: ['Guam', 'Hagåtña', 'en'], 98 | GW: ['Guinea-Bissau', 'Bissau', 'pt'], 99 | GY: ['Guyana', 'Georgetown', 'en'], 100 | HK: ['Hong Kong', 'City of Victoria', 'zh'], 101 | HM: ['Heard Island and McDonald Islands', '', 'en'], 102 | HN: ['Honduras', 'Tegucigalpa', 'es'], 103 | HR: ['Croatia', 'Zagreb', 'hr'], 104 | HT: ['Haiti', 'Port-au-Prince', 'fr'], 105 | HU: ['Hungary', 'Budapest', 'hu'], 106 | ID: ['Indonesia', 'Jakarta', 'id'], 107 | IE: ['Ireland', 'Dublin', 'ga'], 108 | IL: ['Israel', 'Jerusalem', 'he'], 109 | IM: ['Isle of Man', 'Douglas', 'en'], 110 | IN: ['India', 'New Delhi', 'hi'], 111 | IO: ['British Indian Ocean Territory', 'Diego Garcia', 'en'], 112 | IQ: ['Iraq', 'Baghdad', 'ar'], 113 | IR: ['Iran', 'Tehran', 'fa'], 114 | IS: ['Iceland', 'Reykjavik', 'is'], 115 | IT: ['Italy', 'Rome', 'it'], 116 | JE: ['Jersey', 'Saint Helier', 'en'], 117 | JM: ['Jamaica', 'Kingston', 'en'], 118 | JO: ['Jordan', 'Amman', 'ar'], 119 | JP: ['Japan', 'Tokyo', 'ja'], 120 | KE: ['Kenya', 'Nairobi', 'en'], 121 | KG: ['Kyrgyzstan', 'Bishkek', 'ky'], 122 | KH: ['Cambodia', 'Phnom Penh', 'km'], 123 | KI: ['Kiribati', 'South Tarawa', 'en'], 124 | KM: ['Comoros', 'Moroni', 'ar'], 125 | KN: ['Saint Kitts and Nevis', 'Basseterre', 'en'], 126 | KP: ['North Korea', 'Pyongyang', 'ko'], 127 | KR: ['South Korea', 'Seoul', 'ko'], 128 | KW: ['Kuwait', 'Kuwait City', 'ar'], 129 | KY: ['Cayman Islands', 'George Town', 'en'], 130 | KZ: ['Kazakhstan', 'Astana', 'kk'], 131 | LA: ['Laos', 'Vientiane', 'lo'], 132 | LB: ['Lebanon', 'Beirut', 'ar'], 133 | LC: ['Saint Lucia', 'Castries', 'en'], 134 | LI: ['Liechtenstein', 'Vaduz', 'de'], 135 | LK: ['Sri Lanka', 'Colombo', 'si'], 136 | LR: ['Liberia', 'Monrovia', 'en'], 137 | LS: ['Lesotho', 'Maseru', 'en'], 138 | LT: ['Lithuania', 'Vilnius', 'lt'], 139 | LU: ['Luxembourg', 'Luxembourg', 'fr'], 140 | LV: ['Latvia', 'Riga', 'lv'], 141 | LY: ['Libya', 'Tripoli', 'ar'], 142 | MA: ['Morocco', 'Rabat', 'ar'], 143 | MC: ['Monaco', 'Monaco', 'fr'], 144 | MD: ['Moldova', 'Chișinău', 'ro'], 145 | ME: ['Montenegro', 'Podgorica', 'sr'], 146 | MF: ['Saint Martin', 'Marigot', 'en'], 147 | MG: ['Madagascar', 'Antananarivo', 'fr'], 148 | MH: ['Marshall Islands', 'Majuro', 'en'], 149 | MK: ['North Macedonia', 'Skopje', 'mk'], 150 | ML: ['Mali', 'Bamako', 'fr'], 151 | MM: ['Myanmar [Burma]', 'Naypyidaw', 'my'], 152 | MN: ['Mongolia', 'Ulan Bator', 'mn'], 153 | MO: ['Macao', 'Macao', 'zh'], 154 | MP: ['Northern Mariana Islands', 'Saipan', 'en'], 155 | MQ: ['Martinique', 'Fort-de-France', 'fr'], 156 | MR: ['Mauritania', 'Nouakchott', 'ar'], 157 | MS: ['Montserrat', 'Plymouth', 'en'], 158 | MT: ['Malta', 'Valletta', 'mt'], 159 | MU: ['Mauritius', 'Port Louis', 'en'], 160 | MV: ['Maldives', 'Malé', 'dv'], 161 | MW: ['Malawi', 'Lilongwe', 'en'], 162 | MX: ['Mexico', 'Mexico City', 'es'], 163 | MY: ['Malaysia', 'Kuala Lumpur', 'ms'], 164 | MZ: ['Mozambique', 'Maputo', 'pt'], 165 | NA: ['Namibia', 'Windhoek', 'en'], 166 | NC: ['New Caledonia', 'Nouméa', 'fr'], 167 | NE: ['Niger', 'Niamey', 'fr'], 168 | NF: ['Norfolk Island', 'Kingston', 'en'], 169 | NG: ['Nigeria', 'Abuja', 'en'], 170 | NI: ['Nicaragua', 'Managua', 'es'], 171 | NL: ['Netherlands', 'Amsterdam', 'nl'], 172 | NO: ['Norway', 'Oslo', 'no'], 173 | NP: ['Nepal', 'Kathmandu', 'ne'], 174 | NR: ['Nauru', 'Yaren', 'en'], 175 | NU: ['Niue', 'Alofi', 'en'], 176 | NZ: ['New Zealand', 'Wellington', 'en'], 177 | OM: ['Oman', 'Muscat', 'ar'], 178 | PA: ['Panama', 'Panama City', 'es'], 179 | PE: ['Peru', 'Lima', 'es'], 180 | PF: ['French Polynesia', 'Papeetē', 'fr'], 181 | PG: ['Papua New Guinea', 'Port Moresby', 'en'], 182 | PH: ['Philippines', 'Manila', 'en'], 183 | PK: ['Pakistan', 'Islamabad', 'en'], 184 | PL: ['Poland', 'Warsaw', 'pl'], 185 | PM: ['Saint Pierre and Miquelon', 'Saint-Pierre', 'fr'], 186 | PN: ['Pitcairn Islands', 'Adamstown', 'en'], 187 | PR: ['Puerto Rico', 'San Juan', 'es'], 188 | PS: ['Palestine', 'Ramallah', 'ar'], 189 | PT: ['Portugal', 'Lisbon', 'pt'], 190 | PW: ['Palau', 'Ngerulmud', 'en'], 191 | PY: ['Paraguay', 'Asunción', 'es'], 192 | QA: ['Qatar', 'Doha', 'ar'], 193 | RE: ['Réunion', 'Saint-Denis', 'fr'], 194 | RO: ['Romania', 'Bucharest', 'ro'], 195 | RS: ['Serbia', 'Belgrade', 'sr'], 196 | RU: ['Russia', 'Moscow', 'ru'], 197 | RW: ['Rwanda', 'Kigali', 'rw'], 198 | SA: ['Saudi Arabia', 'Riyadh', 'ar'], 199 | SB: ['Solomon Islands', 'Honiara', 'en'], 200 | SC: ['Seychelles', 'Victoria', 'fr'], 201 | SD: ['Sudan', 'Khartoum', 'ar'], 202 | SE: ['Sweden', 'Stockholm', 'sv'], 203 | SG: ['Singapore', 'Singapore', 'en'], 204 | SH: ['Saint Helena', 'Jamestown', 'en'], 205 | SI: ['Slovenia', 'Ljubljana', 'sl'], 206 | SJ: ['Svalbard and Jan Mayen', 'Longyearbyen', 'no'], 207 | SK: ['Slovakia', 'Bratislava', 'sk'], 208 | SL: ['Sierra Leone', 'Freetown', 'en'], 209 | SM: ['San Marino', 'City of San Marino', 'it'], 210 | SN: ['Senegal', 'Dakar', 'fr'], 211 | SO: ['Somalia', 'Mogadishu', 'so'], 212 | SR: ['Suriname', 'Paramaribo', 'nl'], 213 | SS: ['South Sudan', 'Juba', 'en'], 214 | ST: ['São Tomé and Príncipe', 'São Tomé', 'pt'], 215 | SV: ['El Salvador', 'San Salvador', 'es'], 216 | SX: ['Sint Maarten', 'Philipsburg', 'nl'], 217 | SY: ['Syria', 'Damascus', 'ar'], 218 | SZ: ['Swaziland', 'Lobamba', 'en'], 219 | TC: ['Turks and Caicos Islands', 'Cockburn Town', 'en'], 220 | TD: ['Chad', "N'Djamena", 'fr'], 221 | TF: ['French Southern Territories', 'Port-aux-Français', 'fr'], 222 | TG: ['Togo', 'Lomé', 'fr'], 223 | TH: ['Thailand', 'Bangkok', 'th'], 224 | TJ: ['Tajikistan', 'Dushanbe', 'tg'], 225 | TK: ['Tokelau', 'Fakaofo', 'en'], 226 | TL: ['East Timor', 'Dili', 'pt'], 227 | TM: ['Turkmenistan', 'Ashgabat', 'tk'], 228 | TN: ['Tunisia', 'Tunis', 'ar'], 229 | TO: ['Tonga', "Nuku'alofa", 'en'], 230 | TR: ['Turkey', 'Ankara', 'tr'], 231 | TT: ['Trinidad and Tobago', 'Port of Spain', 'en'], 232 | TV: ['Tuvalu', 'Funafuti', 'en'], 233 | TW: ['Taiwan', 'Taipei', 'zh'], 234 | TZ: ['Tanzania', 'Dodoma', 'sw'], 235 | UA: ['Ukraine', 'Kyiv', 'uk'], 236 | UG: ['Uganda', 'Kampala', 'en'], 237 | UM: ['U.S. Minor Outlying Islands', '', 'en'], 238 | US: ['United States', 'New York', 'en'], 239 | UY: ['Uruguay', 'Montevideo', 'es'], 240 | UZ: ['Uzbekistan', 'Tashkent', 'uz'], 241 | VA: ['Vatican City', 'Vatican City', 'it'], 242 | VC: ['Saint Vincent and the Grenadines', 'Kingstown', 'en'], 243 | VE: ['Venezuela', 'Caracas', 'es'], 244 | VG: ['British Virgin Islands', 'Road Town', 'en'], 245 | VI: ['U.S. Virgin Islands', 'Charlotte Amalie', 'en'], 246 | VN: ['Vietnam', 'Hanoi', 'vi'], 247 | VU: ['Vanuatu', 'Port Vila', 'bi'], 248 | WF: ['Wallis and Futuna', 'Mata-Utu', 'fr'], 249 | WS: ['Samoa', 'Apia', 'sm'], 250 | XK: ['Kosovo', 'Pristina', 'sq'], 251 | YE: ['Yemen', "Sana'a", 'ar'], 252 | YT: ['Mayotte', 'Mamoudzou', 'fr'], 253 | ZA: ['South Africa', 'Pretoria', 'af'], 254 | ZM: ['Zambia', 'Lusaka', 'en'], 255 | ZW: ['Zimbabwe', 'Harare', 'en'], 256 | }; 257 | 258 | export default countries; 259 | -------------------------------------------------------------------------------- /__test__/pages/domain.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import SingleDomain from '../../pages/domain/[slug]'; 3 | import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains'; 4 | import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords'; 5 | import { dummyDomain, dummyKeywords } from '../data'; 6 | 7 | jest.mock('../../services/domains'); 8 | jest.mock('../../services/keywords'); 9 | jest.mock('next/router', () => ({ 10 | useRouter: () => ({ 11 | query: { slug: dummyDomain.slug }, 12 | }), 13 | })); 14 | 15 | const useFetchDomainsFunc = useFetchDomains as jest.Mock; 16 | const useFetchKeywordsFunc = useFetchKeywords as jest.Mock; 17 | const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock; 18 | const useFavKeywordsFunc = useFavKeywords as jest.Mock; 19 | const useRefreshKeywordsFunc = useRefreshKeywords as jest.Mock; 20 | const useAddDomainFunc = useAddDomain as jest.Mock; 21 | const useAddKeywordsFunc = useAddKeywords as jest.Mock; 22 | const useUpdateDomainFunc = useUpdateDomain as jest.Mock; 23 | const useDeleteDomainFunc = useDeleteDomain as jest.Mock; 24 | 25 | describe('SingleDomain Page', () => { 26 | beforeEach(() => { 27 | useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false })); 28 | useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false })); 29 | useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); 30 | useFavKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); 31 | useRefreshKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); 32 | useAddDomainFunc.mockImplementation(() => ({ mutate: () => { } })); 33 | useAddKeywordsFunc.mockImplementation(() => ({ mutate: () => { } })); 34 | useUpdateDomainFunc.mockImplementation(() => ({ mutate: () => { } })); 35 | useDeleteDomainFunc.mockImplementation(() => ({ mutate: () => { } })); 36 | }); 37 | afterEach(() => { 38 | jest.clearAllMocks(); 39 | }); 40 | it('Render without crashing.', async () => { 41 | const { getByTestId } = render(); 42 | // screen.debug(undefined, Infinity); 43 | expect(getByTestId('domain-header')).toBeInTheDocument(); 44 | // expect(await result.findByText(/compressimage/i)).toBeInTheDocument(); 45 | }); 46 | it('Should Call the useFetchDomains hook on render.', async () => { 47 | render(); 48 | // screen.debug(undefined, Infinity); 49 | expect(useFetchDomains).toHaveBeenCalled(); 50 | // expect(await result.findByText(/compressimage/i)).toBeInTheDocument(); 51 | }); 52 | it('Should Render the Keywords', async () => { 53 | render(); 54 | const keywordsCount = document.querySelectorAll('.keyword').length; 55 | expect(keywordsCount).toBe(2); 56 | }); 57 | it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => { 58 | render(); 59 | const keywords = document.querySelectorAll('.keyword'); 60 | const firstKeyword = keywords && keywords[0].querySelector('a'); 61 | if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true })); 62 | expect(screen.getByTestId('keywordDetails')).toBeVisible(); 63 | }); 64 | it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => { 65 | render(); 66 | const button = document.querySelector('[data-testid=add_domain]'); 67 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 68 | expect(screen.getByTestId('adddomain_modal')).toBeVisible(); 69 | }); 70 | it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => { 71 | render(); 72 | const button = document.querySelector('[data-testid=add_keyword]'); 73 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 74 | expect(screen.getByTestId('addkeywords_modal')).toBeVisible(); 75 | }); 76 | 77 | it('Should display the Domain Settings on Settings Button click.', async () => { 78 | render(); 79 | const button = document.querySelector('[data-testid=show_domain_settings]'); 80 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 81 | expect(screen.getByTestId('domain_settings')).toBeVisible(); 82 | }); 83 | 84 | it('Device Tab change should be functioning.', async () => { 85 | render(); 86 | const button = document.querySelector('[data-testid=mobile_tab]'); 87 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 88 | const keywordsCount = document.querySelectorAll('.keyword').length; 89 | expect(keywordsCount).toBe(0); 90 | }); 91 | 92 | it('Search Filter should function properly', async () => { 93 | render(); 94 | const inputNode = screen.getByTestId('filter_input'); 95 | fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event 96 | expect(inputNode.getAttribute('value')).toBe('compressor'); 97 | const keywordsCount = document.querySelectorAll('.keyword').length; 98 | expect(keywordsCount).toBe(1); 99 | }); 100 | 101 | it('Country Filter should function properly', async () => { 102 | render(); 103 | const button = document.querySelector('[data-testid=filter_button]'); 104 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 105 | expect(document.querySelector('.country_filter')).toBeVisible(); 106 | 107 | const countrySelect = document.querySelector('.country_filter .selected'); 108 | if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true })); 109 | expect(document.querySelector('.country_filter .select_list')).toBeVisible(); 110 | const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)'); 111 | if (firstCountry) fireEvent(firstCountry, new MouseEvent('click', { bubbles: true })); 112 | const keywordsCount = document.querySelectorAll('.keyword').length; 113 | expect(keywordsCount).toBe(0); 114 | }); 115 | 116 | // Tags Filter should function properly 117 | it('Tags Filter should Render & Function properly', async () => { 118 | render(); 119 | const button = document.querySelector('[data-testid=filter_button]'); 120 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 121 | expect(document.querySelector('.tags_filter')).toBeVisible(); 122 | 123 | const countrySelect = document.querySelector('.tags_filter .selected'); 124 | if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true })); 125 | expect(document.querySelector('.tags_filter .select_list')).toBeVisible(); 126 | expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1); 127 | 128 | const firstTag = document.querySelector('.tags_filter .select_list ul li:nth-child(1)'); 129 | if (firstTag) fireEvent(firstTag, new MouseEvent('click', { bubbles: true })); 130 | expect(document.querySelectorAll('.keyword').length).toBe(1); 131 | }); 132 | 133 | it('Sort Options Should be visible Sort Button on Click.', async () => { 134 | render(); 135 | const button = document.querySelector('[data-testid=sort_button]'); 136 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 137 | expect(document.querySelector('.sort_options')).toBeVisible(); 138 | }); 139 | 140 | it('Sort: Position should sort keywords accordingly', async () => { 141 | render(); 142 | const button = document.querySelector('[data-testid=sort_button]'); 143 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 144 | 145 | // Test Top Position Sort 146 | const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)'); 147 | if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true })); 148 | const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 149 | expect(firstKeywordTitle).toBe('compress image'); 150 | 151 | // Test Lowest Position Sort 152 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 153 | const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)'); 154 | if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true })); 155 | const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 156 | expect(secondKeywordTitle).toBe('image compressor'); 157 | }); 158 | 159 | it('Sort: Date Added should sort keywords accordingly', async () => { 160 | render(); 161 | const button = document.querySelector('[data-testid=sort_button]'); 162 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 163 | 164 | // Test Top Position Sort 165 | const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)'); 166 | if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true })); 167 | const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 168 | expect(firstKeywordTitle).toBe('compress image'); 169 | 170 | // Test Lowest Position Sort 171 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 172 | const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)'); 173 | if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true })); 174 | const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 175 | expect(secondKeywordTitle).toBe('image compressor'); 176 | }); 177 | 178 | it('Sort: Alphabetical should sort keywords accordingly', async () => { 179 | render(); 180 | const button = document.querySelector('[data-testid=sort_button]'); 181 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 182 | 183 | // Test Top Position Sort 184 | const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)'); 185 | if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true })); 186 | const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 187 | expect(firstKeywordTitle).toBe('compress image'); 188 | 189 | // Test Lowest Position Sort 190 | if (button) fireEvent(button, new MouseEvent('click', { bubbles: true })); 191 | const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)'); 192 | if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true })); 193 | const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent; 194 | expect(secondKeywordTitle).toBe('image compressor'); 195 | }); 196 | }); 197 | --------------------------------------------------------------------------------