mirror of
				https://github.com/jaypyles/Scraperr.git
				synced 2025-10-31 14:37:04 +00:00 
			
		
		
		
	Compare commits
	
		
			25 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 8703f706a1 | ||
|   | b40d378bbf | ||
|   | 8123e1f149 | ||
|   | 8cd30599fa | ||
|   | a58212b214 | ||
|   | a6ab6ec71d | ||
|   | c5c9427af4 | ||
|   | e8d80c1a77 | ||
|   | ee8047ac78 | ||
|   | e74c4f392c | ||
|   | 6b484952a3 | ||
|   | 2283808605 | ||
|   | ee5ada70f7 | ||
|   | 56cc457e6e | ||
|   | 21a38181de | ||
|   | 3063bc0d53 | ||
|   | f42e7ed531 | ||
|   | c197f2becd | ||
|   | a534129702 | ||
|   | 455ed049c9 | ||
|   | de4ccfbf3a | ||
|   | 3475d66995 | ||
|   | 186b4a0231 | ||
|   | 0af0ebf5b5 | ||
|   | ef35db00d7 | 
							
								
								
									
										58
									
								
								.github/actions/run-cypress-tests/action.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								.github/actions/run-cypress-tests/action.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| name: Run Cypress Tests | ||||
|  | ||||
| description: Run Cypress tests | ||||
|  | ||||
| runs: | ||||
|   using: "composite" | ||||
|   steps: | ||||
|     - name: Checkout code | ||||
|       uses: actions/checkout@v4 | ||||
|  | ||||
|     - name: Setup Node | ||||
|       uses: actions/setup-node@v4 | ||||
|       with: | ||||
|         node-version: 22 | ||||
|  | ||||
|     - name: Setup Docker project | ||||
|       shell: bash | ||||
|       run: make build up-dev | ||||
|  | ||||
|     - name: Install dependencies | ||||
|       shell: bash | ||||
|       run: npm install | ||||
|  | ||||
|     - name: Wait for frontend to be ready | ||||
|       shell: bash | ||||
|       run: | | ||||
|         for i in {1..10}; do | ||||
|           curl -s http://127.0.0.1:80 && echo "Frontend is ready" && exit 0 | ||||
|           echo "Waiting for frontend to be ready... attempt $i" | ||||
|           sleep 1 | ||||
|         done | ||||
|         echo "Frontend failed to be ready after 10 retries" | ||||
|         exit 1 | ||||
|  | ||||
|     - name: Wait for backend to be ready | ||||
|       shell: bash | ||||
|       run: | | ||||
|         for i in {1..10}; do | ||||
|           curl -s http://127.0.0.1:8000 && echo "Backend is ready" && exit 0 | ||||
|           echo "Waiting for backend to be ready... attempt $i" | ||||
|           sleep 1 | ||||
|         done | ||||
|         echo "Backend failed to be ready after 10 retries" | ||||
|         exit 1 | ||||
|  | ||||
|     - name: Show backend logs on failure | ||||
|       if: failure() | ||||
|       shell: bash | ||||
|       run: | | ||||
|         echo "== Docker Containers ==" | ||||
|         docker ps -a | ||||
|         echo "== Backend Logs ==" | ||||
|         docker logs $(docker ps -a --filter "name=scraperr_api" --format "{{.Names}}") || echo "Could not get backend logs" | ||||
|  | ||||
|     - name: Run Cypress tests | ||||
|       shell: bash | ||||
|       run: npm run cy:run | ||||
|  | ||||
							
								
								
									
										18
									
								
								.github/workflows/docker-image.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								.github/workflows/docker-image.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,7 @@ on: | ||||
|     workflows: ["Unit Tests"] | ||||
|     types: | ||||
|       - completed | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
| @@ -37,3 +38,20 @@ jobs: | ||||
|           file: ./docker/api/Dockerfile | ||||
|           push: true | ||||
|           tags: ${{ secrets.DOCKERHUB_USERNAME }}/scraperr_api:latest | ||||
|  | ||||
|   success-message: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: | ||||
|       - build | ||||
|     steps: | ||||
|       - name: Send Discord Message | ||||
|         uses: jaypyles/discord-webhook-action@v1.0.0 | ||||
|         with: | ||||
|           webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} | ||||
|           content: "Scraperr Successfully Built Docker Images" | ||||
|           username: "Scraperr CI" | ||||
|           embed-title: "✅ Deployment Status" | ||||
|           embed-description: "Scraperr successfully built docker images." | ||||
|           embed-color: 3066993 # Green | ||||
|           embed-footer-text: "Scraperr CI" | ||||
|           embed-timestamp: ${{ github.event.head_commit.timestamp }} | ||||
|   | ||||
							
								
								
									
										30
									
								
								.github/workflows/unit-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										30
									
								
								.github/workflows/unit-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,9 +4,11 @@ on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|  | ||||
|   pull_request: | ||||
|     branches: | ||||
|       - master | ||||
|     types: [opened, synchronize, reopened] | ||||
|  | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   unit-tests: | ||||
| @@ -26,3 +28,27 @@ jobs: | ||||
|  | ||||
|       - name: Run tests | ||||
|         run: PYTHONPATH=. pdm run pytest api/backend/tests | ||||
|  | ||||
|   cypress-tests: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: ./.github/actions/run-cypress-tests | ||||
|  | ||||
|   success-message: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: | ||||
|       - unit-tests | ||||
|       - cypress-tests | ||||
|     steps: | ||||
|       - name: Send Discord Message | ||||
|         uses: jaypyles/discord-webhook-action@v1.0.0 | ||||
|         with: | ||||
|           webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} | ||||
|           content: "Scraperr Successfully Passed Tests" | ||||
|           username: "Scraperr CI" | ||||
|           embed-title: "✅ Deployment Status" | ||||
|           embed-description: "Scraperr successfully passed all tests." | ||||
|           embed-color: 3066993 # Green | ||||
|           embed-footer-text: "Scraperr CI" | ||||
|           embed-timestamp: ${{ github.event.head_commit.timestamp }} | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -187,4 +187,5 @@ cython_debug/ | ||||
| postgres_data | ||||
| .vscode | ||||
| ollama | ||||
| data | ||||
| data | ||||
| media | ||||
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| 3.10.12 | ||||
| @@ -24,6 +24,7 @@ View the [docs](https://scraperr-docs.pages.dev) for a quickstart guide and more | ||||
| - Scrape all pages within same domain | ||||
| - Add custom json headers to send in requests to URLs | ||||
| - Display results of scraped data | ||||
| - Download media found on the page (images, videos, etc.) | ||||
|  | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| github_repo: https://github.com/jaypyles/webapp-template.git | ||||
| deploy_path: /home/admin/site-test6 | ||||
| deploy_command: make pull up-prd | ||||
| @@ -1,10 +0,0 @@ | ||||
| - name: Deploy site | ||||
|   hosts: all | ||||
|   become: true | ||||
|   vars_files: | ||||
|     - ./config.yaml | ||||
|   tasks: | ||||
|     - name: Deploy | ||||
|       command: "{{deploy_command}}" | ||||
|       args: | ||||
|         chdir: "{{deploy_path}}" | ||||
| @@ -1,6 +0,0 @@ | ||||
| all: | ||||
|   hosts: | ||||
|     host1: | ||||
|       ansible_host: 192.168.0.1 | ||||
|       ansible_user: admin | ||||
|       ansible_ssh_private_key_file: private_key.pem | ||||
| @@ -1,54 +0,0 @@ | ||||
| - name: Install Docker and run make pull up | ||||
|   hosts: all | ||||
|   become: true | ||||
|   vars_files: | ||||
|     - ./config.yaml | ||||
|   tasks: | ||||
|     - name: Update apt cache | ||||
|       apt: | ||||
|         update_cache: yes | ||||
|     - name: Install required packages | ||||
|       apt: | ||||
|         name: | ||||
|           - apt-transport-https | ||||
|           - ca-certificates | ||||
|           - curl | ||||
|           - gnupg-agent | ||||
|           - software-properties-common | ||||
|           - rsync | ||||
|           - make | ||||
|         state: present | ||||
|     - name: Add Docker’s official GPG key | ||||
|       apt_key: | ||||
|         url: https://download.docker.com/linux/ubuntu/gpg | ||||
|         state: present | ||||
|     - name: Add Docker APT repository | ||||
|       apt_repository: | ||||
|         repo: deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable | ||||
|         state: present | ||||
|     - name: Update apt cache again after adding Docker repo | ||||
|       apt: | ||||
|         update_cache: yes | ||||
|     - name: Install Docker | ||||
|       apt: | ||||
|         name: docker-ce | ||||
|         state: present | ||||
|     - name: Start and enable Docker service | ||||
|       systemd: | ||||
|         name: docker | ||||
|         enabled: yes | ||||
|         state: started | ||||
|     - name: Install Docker Compose | ||||
|       apt: | ||||
|         name: docker-compose-plugin | ||||
|         state: present | ||||
|     - name: Verify Docker is installed | ||||
|       command: docker --version | ||||
|       register: docker_version | ||||
|     - name: Display Docker version | ||||
|       debug: | ||||
|         msg: "Docker version: {{ docker_version.stdout }}" | ||||
|     - name: Clone repo | ||||
|       ansible.builtin.git: | ||||
|         repo: "{{github_repo}}" | ||||
|         dest: "{{deploy_path}}" | ||||
| @@ -67,4 +67,4 @@ async def ai(c: AI): | ||||
|  | ||||
| @ai_router.get("/ai/check") | ||||
| async def check(): | ||||
|     return JSONResponse(content=bool(open_ai_key or llama_model)) | ||||
|     return JSONResponse(content={"ai_enabled": bool(open_ai_key or llama_model)}) | ||||
|   | ||||
| @@ -1,9 +1,13 @@ | ||||
| # STL | ||||
| import os | ||||
| import logging | ||||
| import apscheduler  # type: ignore | ||||
|  | ||||
| # PDM | ||||
| from fastapi import FastAPI | ||||
| import apscheduler.schedulers | ||||
| import apscheduler.schedulers.background | ||||
| from fastapi import FastAPI, Request, status | ||||
| from fastapi.exceptions import RequestValidationError | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
|  | ||||
| # LOCAL | ||||
| @@ -14,6 +18,10 @@ from api.backend.routers.job_router import job_router | ||||
| from api.backend.routers.log_router import log_router | ||||
| from api.backend.routers.stats_router import stats_router | ||||
| from api.backend.database.startup import init_database | ||||
| from fastapi.responses import JSONResponse | ||||
|  | ||||
| from api.backend.job.cron_scheduling.cron_scheduling import start_cron_scheduler | ||||
| from api.backend.scheduler import scheduler | ||||
|  | ||||
| log_level = os.getenv("LOG_LEVEL") | ||||
| LOG_LEVEL = get_log_level(log_level) | ||||
| @@ -46,6 +54,24 @@ app.include_router(stats_router) | ||||
|  | ||||
| @app.on_event("startup") | ||||
| async def startup_event(): | ||||
|     start_cron_scheduler(scheduler) | ||||
|     scheduler.start() | ||||
|  | ||||
|     if os.getenv("ENV") != "test": | ||||
|         init_database() | ||||
|         LOG.info("Starting up...") | ||||
|  | ||||
|  | ||||
| @app.on_event("shutdown") | ||||
| def shutdown_scheduler(): | ||||
|     scheduler.shutdown(wait=False)  # Set wait=False to not block shutdown | ||||
|  | ||||
|  | ||||
| @app.exception_handler(RequestValidationError) | ||||
| async def validation_exception_handler(request: Request, exc: RequestValidationError): | ||||
|     exc_str = f"{exc}".replace("\n", " ").replace("   ", " ") | ||||
|     logging.error(f"{request}: {exc_str}") | ||||
|     content = {"status_code": 10422, "message": exc_str, "data": None} | ||||
|     return JSONResponse( | ||||
|         content=content, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY | ||||
|     ) | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # STL | ||||
| from datetime import timedelta | ||||
| import os | ||||
|  | ||||
| # PDM | ||||
| from fastapi import Depends, APIRouter, HTTPException, status | ||||
| @@ -14,11 +15,14 @@ from api.backend.auth.auth_utils import ( | ||||
|     get_password_hash, | ||||
|     create_access_token, | ||||
| ) | ||||
| import logging | ||||
|  | ||||
| from api.backend.database.common import update | ||||
|  | ||||
| auth_router = APIRouter() | ||||
|  | ||||
| LOG = logging.getLogger("auth_router") | ||||
|  | ||||
|  | ||||
| @auth_router.post("/auth/token", response_model=Token) | ||||
| async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): | ||||
| @@ -51,9 +55,15 @@ async def create_user(user: UserCreate): | ||||
|  | ||||
|     query = "INSERT INTO users (email, hashed_password, full_name) VALUES (?, ?, ?)" | ||||
|     _ = update(query, (user_dict["email"], hashed_password, user_dict["full_name"])) | ||||
|  | ||||
|     return user_dict | ||||
|  | ||||
|  | ||||
| @auth_router.get("/auth/users/me", response_model=User) | ||||
| async def read_users_me(current_user: User = Depends(get_current_user)): | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| @auth_router.get("/auth/check") | ||||
| async def check_auth(): | ||||
|     return {"registration": os.environ.get("REGISTRATION_ENABLED", "True") == "True"} | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| # STL | ||||
| import os | ||||
| from gc import disable | ||||
| from queue import Empty | ||||
| from typing import Any, Optional | ||||
| from datetime import datetime, timedelta | ||||
| import logging | ||||
| @@ -78,27 +76,42 @@ def create_access_token( | ||||
|  | ||||
|  | ||||
| async def get_current_user(token: str = Depends(oauth2_scheme)): | ||||
|     LOG.info(f"Getting current user with token: {token}") | ||||
|     LOG.debug(f"Getting current user with token: {token}") | ||||
|  | ||||
|     if not token: | ||||
|         LOG.debug("No token provided") | ||||
|         return EMPTY_USER | ||||
|  | ||||
|     if len(token.split(".")) != 3: | ||||
|         LOG.error(f"Malformed token: {token}") | ||||
|         return EMPTY_USER | ||||
|  | ||||
|     try: | ||||
|         LOG.debug( | ||||
|             f"Decoding token: {token} with secret key: {SECRET_KEY} and algorithm: {ALGORITHM}" | ||||
|         ) | ||||
|  | ||||
|         if token.startswith("Bearer "): | ||||
|             token = token.split(" ")[1] | ||||
|  | ||||
|         payload: Optional[dict[str, Any]] = jwt.decode( | ||||
|             token, SECRET_KEY, algorithms=[ALGORITHM] | ||||
|         ) | ||||
|  | ||||
|         if not payload: | ||||
|             LOG.error("No payload found in token") | ||||
|             return EMPTY_USER | ||||
|  | ||||
|         email = payload.get("sub") | ||||
|  | ||||
|         if email is None: | ||||
|             LOG.error("No email found in payload") | ||||
|             return EMPTY_USER | ||||
|  | ||||
|         token_data = TokenData(email=email) | ||||
|  | ||||
|     except JWTError: | ||||
|     except JWTError as e: | ||||
|         LOG.error(f"JWTError occurred: {e}") | ||||
|         return EMPTY_USER | ||||
|  | ||||
|     except Exception as e: | ||||
| @@ -106,7 +119,6 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): | ||||
|         return EMPTY_USER | ||||
|  | ||||
|     user = await get_user(email=token_data.email) | ||||
|  | ||||
|     if user is None: | ||||
|         return EMPTY_USER | ||||
|  | ||||
|   | ||||
| @@ -17,4 +17,14 @@ CREATE TABLE IF NOT EXISTS users ( | ||||
|     full_name STRING, | ||||
|     disabled BOOLEAN | ||||
| ); | ||||
|  | ||||
| CREATE TABLE IF NOT EXISTS cron_jobs ( | ||||
|     id STRING PRIMARY KEY NOT NULL, | ||||
|     user_email STRING NOT NULL, | ||||
|     job_id STRING NOT NULL, | ||||
|     cron_expression STRING NOT NULL, | ||||
|     time_created DATETIME NOT NULL, | ||||
|     time_updated DATETIME NOT NULL, | ||||
|     FOREIGN KEY (job_id) REFERENCES jobs(id) | ||||
| ); | ||||
| """ | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| import os | ||||
| from api.backend.database.common import connect, QUERIES | ||||
| import logging | ||||
|  | ||||
| from api.backend.auth.auth_utils import get_password_hash | ||||
|  | ||||
| LOG = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| @@ -12,4 +15,29 @@ def init_database(): | ||||
|             LOG.info(f"Executing query: {query}") | ||||
|             _ = cursor.execute(query) | ||||
|  | ||||
|     if os.environ.get("REGISTRATION_ENABLED", "True") == "False": | ||||
|         default_user_email = os.environ.get("DEFAULT_USER_EMAIL") | ||||
|         default_user_password = os.environ.get("DEFAULT_USER_PASSWORD") | ||||
|         default_user_full_name = os.environ.get("DEFAULT_USER_FULL_NAME") | ||||
|  | ||||
|         if ( | ||||
|             not default_user_email | ||||
|             or not default_user_password | ||||
|             or not default_user_full_name | ||||
|         ): | ||||
|             LOG.error( | ||||
|                 "DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD, or DEFAULT_USER_FULL_NAME is not set!" | ||||
|             ) | ||||
|             exit(1) | ||||
|  | ||||
|         query = "INSERT INTO users (email, hashed_password, full_name) VALUES (?, ?, ?)" | ||||
|         _ = cursor.execute( | ||||
|             query, | ||||
|             ( | ||||
|                 default_user_email, | ||||
|                 get_password_hash(default_user_password), | ||||
|                 default_user_full_name, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     cursor.close() | ||||
|   | ||||
							
								
								
									
										100
									
								
								api/backend/job/cron_scheduling/cron_scheduling.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								api/backend/job/cron_scheduling/cron_scheduling.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import datetime | ||||
| from typing import Any | ||||
| import uuid | ||||
| from api.backend.database.common import insert, query | ||||
| from api.backend.models import CronJob | ||||
| from apscheduler.schedulers.background import BackgroundScheduler  # type: ignore | ||||
| from apscheduler.triggers.cron import CronTrigger  # type: ignore | ||||
|  | ||||
| from api.backend.job import insert as insert_job | ||||
| import logging | ||||
|  | ||||
| LOG = logging.getLogger("Cron Scheduler") | ||||
|  | ||||
|  | ||||
| def insert_cron_job(cron_job: CronJob): | ||||
|     query = """ | ||||
|     INSERT INTO cron_jobs (id, user_email, job_id, cron_expression, time_created, time_updated) | ||||
|     VALUES (?, ?, ?, ?, ?, ?) | ||||
|     """ | ||||
|     values = ( | ||||
|         cron_job.id, | ||||
|         cron_job.user_email, | ||||
|         cron_job.job_id, | ||||
|         cron_job.cron_expression, | ||||
|         cron_job.time_created, | ||||
|         cron_job.time_updated, | ||||
|     ) | ||||
|  | ||||
|     insert(query, values) | ||||
|  | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def delete_cron_job(id: str, user_email: str): | ||||
|     query = """ | ||||
|     DELETE FROM cron_jobs | ||||
|     WHERE id = ? AND user_email = ? | ||||
|     """ | ||||
|     values = (id, user_email) | ||||
|     insert(query, values) | ||||
|  | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def get_cron_jobs(user_email: str): | ||||
|     cron_jobs = query("SELECT * FROM cron_jobs WHERE user_email = ?", (user_email,)) | ||||
|  | ||||
|     return cron_jobs | ||||
|  | ||||
|  | ||||
| def get_all_cron_jobs(): | ||||
|     cron_jobs = query("SELECT * FROM cron_jobs") | ||||
|  | ||||
|     return cron_jobs | ||||
|  | ||||
|  | ||||
| def insert_job_from_cron_job(job: dict[str, Any]): | ||||
|     insert_job( | ||||
|         { | ||||
|             **job, | ||||
|             "id": uuid.uuid4().hex, | ||||
|             "status": "Queued", | ||||
|             "result": "", | ||||
|             "chat": None, | ||||
|             "time_created": datetime.datetime.now(), | ||||
|             "time_updated": datetime.datetime.now(), | ||||
|         } | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def get_cron_job_trigger(cron_expression: str): | ||||
|     expression_parts = cron_expression.split() | ||||
|  | ||||
|     if len(expression_parts) != 5: | ||||
|         print(f"Invalid cron expression: {cron_expression}") | ||||
|         return None | ||||
|  | ||||
|     minute, hour, day, month, day_of_week = expression_parts | ||||
|  | ||||
|     return CronTrigger( | ||||
|         minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def start_cron_scheduler(scheduler: BackgroundScheduler): | ||||
|     cron_jobs = get_all_cron_jobs() | ||||
|  | ||||
|     LOG.info(f"Cron jobs: {cron_jobs}") | ||||
|  | ||||
|     for job in cron_jobs: | ||||
|         queried_job = query("SELECT * FROM jobs WHERE id = ?", (job["job_id"],)) | ||||
|  | ||||
|         LOG.info(f"Adding job: {queried_job}") | ||||
|  | ||||
|         scheduler.add_job( | ||||
|             insert_job_from_cron_job, | ||||
|             get_cron_job_trigger(job["cron_expression"]), | ||||
|             id=job["id"], | ||||
|             args=[queried_job[0]], | ||||
|         ) | ||||
| @@ -14,7 +14,7 @@ from api.backend.database.common import ( | ||||
| LOG = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| async def insert(item: dict[str, Any]) -> None: | ||||
| def insert(item: dict[str, Any]) -> None: | ||||
|     common_insert( | ||||
|         QUERIES["insert_job"], | ||||
|         ( | ||||
|   | ||||
| @@ -12,3 +12,4 @@ class JobOptions(BaseModel): | ||||
|     custom_headers: dict[str, Any] = {} | ||||
|     proxies: list[str] = [] | ||||
|     site_map: Optional[SiteMap] = None | ||||
|     collect_media: bool = False | ||||
|   | ||||
							
								
								
									
										91
									
								
								api/backend/job/scraping/collect_media.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								api/backend/job/scraping/collect_media.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| import os | ||||
| import requests | ||||
| from pathlib import Path | ||||
| from selenium.webdriver.common.by import By | ||||
| from seleniumwire import webdriver | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from api.backend.utils import LOG | ||||
|  | ||||
|  | ||||
| def collect_media(driver: webdriver.Chrome): | ||||
|     media_types = { | ||||
|         "images": "img", | ||||
|         "videos": "video", | ||||
|         "audio": "audio", | ||||
|         "pdfs": 'a[href$=".pdf"]', | ||||
|         "documents": 'a[href$=".doc"], a[href$=".docx"], a[href$=".txt"], a[href$=".rtf"]', | ||||
|         "presentations": 'a[href$=".ppt"], a[href$=".pptx"]', | ||||
|         "spreadsheets": 'a[href$=".xls"], a[href$=".xlsx"], a[href$=".csv"]', | ||||
|     } | ||||
|  | ||||
|     base_dir = Path("media") | ||||
|     base_dir.mkdir(exist_ok=True) | ||||
|  | ||||
|     media_urls = {} | ||||
|  | ||||
|     for media_type, selector in media_types.items(): | ||||
|         elements = driver.find_elements(By.CSS_SELECTOR, selector) | ||||
|         urls: list[dict[str, str]] = [] | ||||
|  | ||||
|         media_dir = base_dir / media_type | ||||
|         media_dir.mkdir(exist_ok=True) | ||||
|  | ||||
|         for element in elements: | ||||
|             if media_type == "images": | ||||
|                 url = element.get_attribute("src") | ||||
|             elif media_type == "videos": | ||||
|                 url = element.get_attribute("src") or element.get_attribute("data-src") | ||||
|             else: | ||||
|                 url = element.get_attribute("href") | ||||
|  | ||||
|             if url and url.startswith(("http://", "https://")): | ||||
|                 try: | ||||
|                     filename = os.path.basename(urlparse(url).path) | ||||
|  | ||||
|                     if not filename: | ||||
|                         filename = f"{media_type}_{len(urls)}" | ||||
|  | ||||
|                         if media_type == "images": | ||||
|                             filename += ".jpg" | ||||
|                         elif media_type == "videos": | ||||
|                             filename += ".mp4" | ||||
|                         elif media_type == "audio": | ||||
|                             filename += ".mp3" | ||||
|                         elif media_type == "pdfs": | ||||
|                             filename += ".pdf" | ||||
|                         elif media_type == "documents": | ||||
|                             filename += ".doc" | ||||
|                         elif media_type == "presentations": | ||||
|                             filename += ".ppt" | ||||
|                         elif media_type == "spreadsheets": | ||||
|                             filename += ".xls" | ||||
|  | ||||
|                     response = requests.get(url, stream=True) | ||||
|                     response.raise_for_status() | ||||
|  | ||||
|                     # Save the file | ||||
|                     file_path = media_dir / filename | ||||
|                     with open(file_path, "wb") as f: | ||||
|                         for chunk in response.iter_content(chunk_size=8192): | ||||
|                             if chunk: | ||||
|                                 f.write(chunk) | ||||
|  | ||||
|                     urls.append({"url": url, "local_path": str(file_path)}) | ||||
|                     LOG.info(f"Downloaded {filename} to {file_path}") | ||||
|  | ||||
|                 except Exception as e: | ||||
|                     LOG.error(f"Error downloading {url}: {str(e)}") | ||||
|                     continue | ||||
|  | ||||
|         media_urls[media_type] = urls | ||||
|  | ||||
|     with open(base_dir / "download_summary.txt", "w") as f: | ||||
|         for media_type, downloads in media_urls.items(): | ||||
|             if downloads: | ||||
|                 f.write(f"\n=== {media_type.upper()} ===\n") | ||||
|                 for download in downloads: | ||||
|                     f.write(f"URL: {download['url']}\n") | ||||
|                     f.write(f"Saved to: {download['local_path']}\n\n") | ||||
|  | ||||
|     return media_urls | ||||
| @@ -1,13 +1,19 @@ | ||||
| import time | ||||
| from typing import cast | ||||
|  | ||||
| from selenium import webdriver | ||||
| from seleniumwire import webdriver | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.support import expected_conditions as EC | ||||
| from selenium.webdriver.support.ui import WebDriverWait | ||||
|  | ||||
| from api.backend.utils import LOG | ||||
|  | ||||
| def scrape_content(driver: webdriver.Chrome, pages: set[tuple[str, str]]): | ||||
| from api.backend.job.scraping.collect_media import collect_media as collect_media_utils | ||||
|  | ||||
|  | ||||
| def scrape_content( | ||||
|     driver: webdriver.Chrome, pages: set[tuple[str, str]], collect_media: bool | ||||
| ): | ||||
|     _ = WebDriverWait(driver, 10).until( | ||||
|         EC.presence_of_element_located((By.TAG_NAME, "body")) | ||||
|     ) | ||||
| @@ -27,4 +33,9 @@ def scrape_content(driver: webdriver.Chrome, pages: set[tuple[str, str]]): | ||||
|         last_height = new_height | ||||
|  | ||||
|     pages.add((driver.page_source, driver.current_url)) | ||||
|  | ||||
|     if collect_media: | ||||
|         LOG.info("Collecting media") | ||||
|         collect_media_utils(driver) | ||||
|  | ||||
|     return driver.page_source | ||||
|   | ||||
| @@ -57,3 +57,17 @@ class Job(pydantic.BaseModel): | ||||
|     job_options: JobOptions | ||||
|     status: str = "Queued" | ||||
|     chat: Optional[str] = None | ||||
|  | ||||
|  | ||||
| class CronJob(pydantic.BaseModel): | ||||
|     id: Optional[str] = None | ||||
|     user_email: str | ||||
|     job_id: str | ||||
|     cron_expression: str | ||||
|     time_created: Optional[Union[datetime, str]] = None | ||||
|     time_updated: Optional[Union[datetime, str]] = None | ||||
|  | ||||
|  | ||||
| class DeleteCronJob(pydantic.BaseModel): | ||||
|     id: str | ||||
|     user_email: str | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| # STL | ||||
| import datetime | ||||
| import uuid | ||||
| import traceback | ||||
| from io import StringIO | ||||
| @@ -10,14 +11,18 @@ import random | ||||
| from fastapi import Depends, APIRouter | ||||
| from fastapi.encoders import jsonable_encoder | ||||
| from fastapi.responses import JSONResponse, StreamingResponse | ||||
| from api.backend.scheduler import scheduler | ||||
| from apscheduler.triggers.cron import CronTrigger  # type: ignore | ||||
|  | ||||
| # LOCAL | ||||
| from api.backend.job import insert, update_job, delete_jobs | ||||
| from api.backend.models import ( | ||||
|     DeleteCronJob, | ||||
|     UpdateJobs, | ||||
|     DownloadJob, | ||||
|     DeleteScrapeJobs, | ||||
|     Job, | ||||
|     CronJob, | ||||
| ) | ||||
| from api.backend.schemas import User | ||||
| from api.backend.auth.auth_utils import get_current_user | ||||
| @@ -26,6 +31,14 @@ from api.backend.job.models.job_options import FetchOptions | ||||
|  | ||||
| from api.backend.database.common import query | ||||
|  | ||||
| from api.backend.job.cron_scheduling.cron_scheduling import ( | ||||
|     delete_cron_job, | ||||
|     get_cron_job_trigger, | ||||
|     insert_cron_job, | ||||
|     get_cron_jobs, | ||||
|     insert_job_from_cron_job, | ||||
| ) | ||||
|  | ||||
| LOG = logging.getLogger(__name__) | ||||
|  | ||||
| job_router = APIRouter() | ||||
| @@ -44,7 +57,7 @@ async def submit_scrape_job(job: Job): | ||||
|         job.id = uuid.uuid4().hex | ||||
|  | ||||
|         job_dict = job.model_dump() | ||||
|         await insert(job_dict) | ||||
|         insert(job_dict) | ||||
|  | ||||
|         return JSONResponse(content={"id": job.id}) | ||||
|     except Exception as e: | ||||
| @@ -140,3 +153,47 @@ async def delete(delete_scrape_jobs: DeleteScrapeJobs): | ||||
|         if result | ||||
|         else JSONResponse({"error": "Jobs not deleted."}) | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @job_router.post("/schedule-cron-job") | ||||
| async def schedule_cron_job(cron_job: CronJob): | ||||
|     if not cron_job.id: | ||||
|         cron_job.id = uuid.uuid4().hex | ||||
|  | ||||
|     if not cron_job.time_created: | ||||
|         cron_job.time_created = datetime.datetime.now() | ||||
|  | ||||
|     if not cron_job.time_updated: | ||||
|         cron_job.time_updated = datetime.datetime.now() | ||||
|  | ||||
|     insert_cron_job(cron_job) | ||||
|  | ||||
|     queried_job = query("SELECT * FROM jobs WHERE id = ?", (cron_job.job_id,)) | ||||
|  | ||||
|     scheduler.add_job( | ||||
|         insert_job_from_cron_job, | ||||
|         get_cron_job_trigger(cron_job.cron_expression), | ||||
|         id=cron_job.id, | ||||
|         args=[queried_job[0]], | ||||
|     ) | ||||
|  | ||||
|     return JSONResponse(content={"message": "Cron job scheduled successfully."}) | ||||
|  | ||||
|  | ||||
| @job_router.post("/delete-cron-job") | ||||
| async def delete_cron_job_request(request: DeleteCronJob): | ||||
|     if not request.id: | ||||
|         return JSONResponse( | ||||
|             content={"error": "Cron job id is required."}, status_code=400 | ||||
|         ) | ||||
|  | ||||
|     delete_cron_job(request.id, request.user_email) | ||||
|     scheduler.remove_job(request.id) | ||||
|  | ||||
|     return JSONResponse(content={"message": "Cron job deleted successfully."}) | ||||
|  | ||||
|  | ||||
| @job_router.get("/cron-jobs") | ||||
| async def get_cron_jobs_request(user: User = Depends(get_current_user)): | ||||
|     cron_jobs = get_cron_jobs(user.email) | ||||
|     return JSONResponse(content=jsonable_encoder(cron_jobs)) | ||||
|   | ||||
							
								
								
									
										3
									
								
								api/backend/scheduler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								api/backend/scheduler.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from apscheduler.schedulers.background import BackgroundScheduler  # type: ignore | ||||
|  | ||||
| scheduler = BackgroundScheduler() | ||||
| @@ -2,10 +2,10 @@ import logging | ||||
| from typing import Any, Optional | ||||
| import random | ||||
|  | ||||
| from bs4 import BeautifulSoup | ||||
| from bs4 import BeautifulSoup, Tag | ||||
| from lxml import etree | ||||
| from seleniumwire import webdriver | ||||
| from lxml.etree import _Element  # pyright: ignore [reportPrivateUsage] | ||||
| from lxml.etree import _Element | ||||
| from fake_useragent import UserAgent | ||||
| from selenium.webdriver.chrome.options import Options as ChromeOptions | ||||
| from urllib.parse import urlparse, urljoin | ||||
| @@ -13,8 +13,9 @@ from api.backend.models import Element, CapturedElement | ||||
| from api.backend.job.site_mapping.site_mapping import ( | ||||
|     handle_site_mapping, | ||||
| ) | ||||
| from selenium.webdriver.chrome.service import Service | ||||
| from webdriver_manager.chrome import ChromeDriverManager | ||||
| from api.backend.job.scraping.scraping_utils import scrape_content | ||||
| from api.backend.job.models.site_map import SiteMap | ||||
|  | ||||
| LOG = logging.getLogger(__name__) | ||||
|  | ||||
| @@ -70,21 +71,27 @@ def create_driver(proxies: Optional[list[str]] = []): | ||||
|     chrome_options.add_argument(f"user-agent={ua.random}") | ||||
|  | ||||
|     sw_options = {} | ||||
|  | ||||
|     if proxies: | ||||
|         selected_proxy = proxies[random.randint(0, len(proxies) - 1)] | ||||
|         selected_proxy = random.choice(proxies) | ||||
|         LOG.info(f"Using proxy: {selected_proxy}") | ||||
|  | ||||
|         sw_options = { | ||||
|             "proxy": { | ||||
|                 "https": f"https://{selected_proxy}", | ||||
|                 "http": f"http://{selected_proxy}", | ||||
|                 "no_proxy": "localhost,127.0.0.1", | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     service = Service(ChromeDriverManager().install()) | ||||
|  | ||||
|     driver = webdriver.Chrome( | ||||
|         service=service, | ||||
|         options=chrome_options, | ||||
|         seleniumwire_options=sw_options, | ||||
|     ) | ||||
|  | ||||
|     return driver | ||||
|  | ||||
|  | ||||
| @@ -97,6 +104,7 @@ async def make_site_request( | ||||
|     original_url: str = "", | ||||
|     proxies: Optional[list[str]] = [], | ||||
|     site_map: Optional[dict[str, Any]] = None, | ||||
|     collect_media: bool = False, | ||||
| ) -> None: | ||||
|     """Make basic `GET` request to site using Selenium.""" | ||||
|     # Check if URL has already been visited | ||||
| @@ -117,7 +125,7 @@ async def make_site_request( | ||||
|         visited_urls.add(url) | ||||
|         visited_urls.add(final_url) | ||||
|  | ||||
|         page_source = scrape_content(driver, pages) | ||||
|         page_source = scrape_content(driver, pages, collect_media) | ||||
|  | ||||
|         if site_map: | ||||
|             LOG.info("Site map: %s", site_map) | ||||
| @@ -135,7 +143,10 @@ async def make_site_request( | ||||
|     soup = BeautifulSoup(page_source, "html.parser") | ||||
|  | ||||
|     for a_tag in soup.find_all("a"): | ||||
|         link = a_tag.get("href") | ||||
|         if not isinstance(a_tag, Tag): | ||||
|             continue | ||||
|  | ||||
|         link = str(a_tag.get("href", "")) | ||||
|  | ||||
|         if link: | ||||
|             if not urlparse(link).netloc: | ||||
| @@ -163,7 +174,10 @@ async def collect_scraped_elements(page: tuple[str, str], xpaths: list[Element]) | ||||
|         el = sxpath(root, elem.xpath) | ||||
|  | ||||
|         for e in el: | ||||
|             text = "\t".join(str(t) for t in e.itertext()) | ||||
|             if isinstance(e, etree._Element):  # type: ignore | ||||
|                 text = "\t".join(str(t) for t in e.itertext()) | ||||
|             else: | ||||
|                 text = str(e) | ||||
|             captured_element = CapturedElement( | ||||
|                 xpath=elem.xpath, text=text, name=elem.name | ||||
|             ) | ||||
| @@ -183,7 +197,8 @@ async def scrape( | ||||
|     headers: Optional[dict[str, Any]], | ||||
|     multi_page_scrape: bool = False, | ||||
|     proxies: Optional[list[str]] = [], | ||||
|     site_map: Optional[SiteMap] = None, | ||||
|     site_map: Optional[dict[str, Any]] = None, | ||||
|     collect_media: bool = False, | ||||
| ): | ||||
|     visited_urls: set[str] = set() | ||||
|     pages: set[tuple[str, str]] = set() | ||||
| @@ -197,6 +212,7 @@ async def scrape( | ||||
|         original_url=url, | ||||
|         proxies=proxies, | ||||
|         site_map=site_map, | ||||
|         collect_media=collect_media, | ||||
|     ) | ||||
|  | ||||
|     elements: list[dict[str, dict[str, list[CapturedElement]]]] = list() | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import logging | ||||
| import sys | ||||
| import traceback | ||||
|  | ||||
| from api.backend.database.startup import init_database | ||||
|  | ||||
| logging.basicConfig(stream=sys.stdout, level=logging.INFO) | ||||
| LOG = logging.getLogger(__name__) | ||||
|  | ||||
| @@ -25,6 +27,7 @@ async def process_job(): | ||||
|                 job["job_options"]["multi_page_scrape"], | ||||
|                 job["job_options"]["proxies"], | ||||
|                 job["job_options"]["site_map"], | ||||
|                 job["job_options"]["collect_media"], | ||||
|             ) | ||||
|             LOG.info( | ||||
|                 f"Scraped result for url: {job['url']}, with elements: {job['elements']}\n{scraped}" | ||||
| @@ -41,6 +44,9 @@ async def process_job(): | ||||
|  | ||||
| async def main(): | ||||
|     LOG.info("Starting job worker...") | ||||
|  | ||||
|     init_database() | ||||
|  | ||||
|     while True: | ||||
|         await process_job() | ||||
|         await asyncio.sleep(5) | ||||
|   | ||||
							
								
								
									
										60
									
								
								cypress/e2e/authentication.cy.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								cypress/e2e/authentication.cy.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| describe("Authentication", () => { | ||||
|   it("should register", () => { | ||||
|     cy.intercept("POST", "/api/signup").as("signup"); | ||||
|  | ||||
|     cy.visit("/").then(() => { | ||||
|       cy.get("button").contains("Login").click(); | ||||
|       cy.url().should("include", "/login"); | ||||
|  | ||||
|       cy.get("form").should("be.visible"); | ||||
|       cy.get("button") | ||||
|         .contains("No Account? Sign up") | ||||
|         .should("be.visible") | ||||
|         .click(); | ||||
|  | ||||
|       cy.get("input[name='email']").type("test@test.com"); | ||||
|       cy.get("input[name='password']").type("password"); | ||||
|       cy.get("input[name='fullName']").type("John Doe"); | ||||
|       cy.get("button[type='submit']").contains("Signup").click(); | ||||
|  | ||||
|       cy.wait("@signup").then((interception) => { | ||||
|         if (!interception.response) { | ||||
|           cy.log("No response received!"); | ||||
|           throw new Error("signup request did not return a response"); | ||||
|         } | ||||
|  | ||||
|         cy.log("Response status: " + interception.response.statusCode); | ||||
|         cy.log("Response body: " + JSON.stringify(interception.response.body)); | ||||
|  | ||||
|         expect(interception.response.statusCode).to.eq(200); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it("should login", () => { | ||||
|     cy.intercept("POST", "/api/token").as("token"); | ||||
|  | ||||
|     cy.visit("/").then(() => { | ||||
|       cy.get("button") | ||||
|         .contains("Login") | ||||
|         .click() | ||||
|         .then(() => { | ||||
|           cy.get("input[name='email']").type("test@test.com"); | ||||
|           cy.get("input[name='password']").type("password"); | ||||
|           cy.get("button[type='submit']").contains("Login").click(); | ||||
|  | ||||
|           cy.wait("@token").then((interception) => { | ||||
|             if (!interception.response) { | ||||
|               cy.log("No response received!"); | ||||
|               throw new Error("token request did not return a response"); | ||||
|             } | ||||
|  | ||||
|             cy.log("Response status: " + interception.response.statusCode); | ||||
|             cy.log("Response body: " + JSON.stringify(interception.response.body)); | ||||
|  | ||||
|             expect(interception.response.statusCode).to.eq(200); | ||||
|           }); | ||||
|         }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,19 +1,34 @@ | ||||
| describe("Job", () => { | ||||
| describe.only("Job", () => { | ||||
|   it("should create a job", () => { | ||||
|     cy.intercept("POST", "/api/submit-scrape-job").as("submitScrapeJob"); | ||||
|  | ||||
|     cy.visit("/"); | ||||
|  | ||||
|     const input = cy.get('[data-cy="url-input"]'); | ||||
|     input.type("https://example.com"); | ||||
|     cy.get('[data-cy="url-input"]').type("https://example.com"); | ||||
|     cy.get('[data-cy="name-field"]').type("example"); | ||||
|     cy.get('[data-cy="xpath-field"]').type("//body"); | ||||
|     cy.get('[data-cy="add-button"]').click(); | ||||
|  | ||||
|     const nameField = cy.get('[data-cy="name-field"]'); | ||||
|     const xPathField = cy.get('[data-cy="xpath-field"]'); | ||||
|     const addButton = cy.get('[data-cy="add-button"]'); | ||||
|     cy.contains("Submit").click(); | ||||
|  | ||||
|     nameField.type("example"); | ||||
|     xPathField.type("//body"); | ||||
|     addButton.click(); | ||||
|     cy.wait("@submitScrapeJob").then((interception) => { | ||||
|       if (!interception.response) { | ||||
|         cy.log("No response received!"); | ||||
|         cy.log("Request body: " + JSON.stringify(interception.request?.body)); | ||||
|         throw new Error("submitScrapeJob request did not return a response"); | ||||
|       } | ||||
|  | ||||
|     const submit = cy.contains("Submit"); | ||||
|     submit.click(); | ||||
|       cy.log("Response status: " + interception.response.statusCode); | ||||
|       cy.log("Response body: " + JSON.stringify(interception.response.body)); | ||||
|  | ||||
|       expect(interception.response.statusCode).to.eq(200); | ||||
|     }); | ||||
|  | ||||
|     cy.get("li").contains("Previous Jobs").click(); | ||||
|  | ||||
|     cy.contains("div", "https://example.com", { timeout: 10000 }).should( | ||||
|       "exist" | ||||
|     ); | ||||
|     cy.contains("div", "Completed", { timeout: 20000 }).should("exist"); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -34,4 +34,4 @@ | ||||
| //       visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element> | ||||
| //     } | ||||
| //   } | ||||
| // } | ||||
| // } | ||||
|   | ||||
| @@ -23,7 +23,7 @@ services: | ||||
|       dockerfile: docker/api/Dockerfile | ||||
|     environment: | ||||
|       - LOG_LEVEL=INFO | ||||
|       - SECRET_KEY=your_secret_key # used to encode authentication tokens (can be a random string) | ||||
|       - SECRET_KEY=MRo9PfasPibnqFeK4Oswb6Z+PhFmjzdvxZzwdAkbf/Y= # used to encode authentication tokens (can be a random string) | ||||
|       - ALGORITHM=HS256 # authentication encoding algorithm | ||||
|       - ACCESS_TOKEN_EXPIRE_MINUTES=600 # access token expire minutes | ||||
|     container_name: scraperr_api | ||||
| @@ -31,6 +31,7 @@ services: | ||||
|       - 8000:8000 | ||||
|     volumes: | ||||
|       - "$PWD/data:/project/data" | ||||
|       - "$PWD/media:/project/media" | ||||
|       - /var/run/docker.sock:/var/run/docker.sock | ||||
|     networks: | ||||
|       - web | ||||
|   | ||||
							
								
								
									
										15660
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15660
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										12
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								package.json
									
									
									
									
									
								
							| @@ -19,6 +19,7 @@ | ||||
|     "bootstrap": "^5.3.0", | ||||
|     "chart.js": "^4.4.3", | ||||
|     "cookie": "^0.6.0", | ||||
|     "dotenv": "^16.5.0", | ||||
|     "framer-motion": "^4.1.17", | ||||
|     "js-cookie": "^3.0.5", | ||||
|     "next": "^14.2.4", | ||||
| @@ -31,7 +32,6 @@ | ||||
|     "react-modal-image": "^2.6.0", | ||||
|     "react-router": "^6.14.1", | ||||
|     "react-router-dom": "^6.14.1", | ||||
|     "react-scripts": "^5.0.1", | ||||
|     "react-spinners": "^0.14.1", | ||||
|     "typescript": "^4.9.5", | ||||
|     "web-vitals": "^2.1.4" | ||||
| @@ -63,12 +63,18 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/cypress": "^0.1.6", | ||||
|     "@types/cypress": "^1.1.6", | ||||
|     "@types/js-cookie": "^3.0.6", | ||||
|     "cypress": "^13.15.0", | ||||
|     "autoprefixer": "^10.4.21", | ||||
|     "cypress": "^13.17.0", | ||||
|     "eslint": "^9.26.0", | ||||
|     "postcss": "^8.5.3", | ||||
|     "tailwindcss": "^3.3.5" | ||||
|   }, | ||||
|   "overrides": { | ||||
|     "react-refresh": "0.11.0" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "postcss": "^8.4.31" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,9 +2,7 @@ | ||||
| name = "web-scrape" | ||||
| version = "0.1.0" | ||||
| description = "" | ||||
| authors = [ | ||||
|     {name = "Jayden Pyles", email = "jpylesbuisness@gmail.com"}, | ||||
| ] | ||||
| authors = [{ name = "Jayden Pyles", email = "jpylesbuisness@gmail.com" }] | ||||
| dependencies = [ | ||||
|     "uvicorn>=0.30.1", | ||||
|     "fastapi>=0.111.0", | ||||
| @@ -39,20 +37,19 @@ dependencies = [ | ||||
|     "exceptiongroup>=1.2.2", | ||||
|     "Faker>=30.6.0", | ||||
|     "pytest-asyncio>=0.24.0", | ||||
|     "python-multipart>=0.0.12", | ||||
|     "python-multipart>=0.0.1", | ||||
|     "bcrypt==4.0.1", | ||||
|     "apscheduler>=3.11.0", | ||||
| ] | ||||
| requires-python = ">=3.10" | ||||
| readme = "README.md" | ||||
| license = {text = "MIT"} | ||||
| license = { text = "MIT" } | ||||
|  | ||||
| [tool.pdm] | ||||
| distribution = true | ||||
|  | ||||
| [tool.pdm.dev-dependencies] | ||||
| dev = [ | ||||
|     "ipython>=8.26.0", | ||||
|     "pytest>=8.3.3", | ||||
| ] | ||||
| dev = ["ipython>=8.26.0", "pytest>=8.3.3"] | ||||
| [tool.pyright] | ||||
| include = ["./api/backend/"] | ||||
| exclude = ["**/node_modules", "**/__pycache__"] | ||||
| @@ -60,14 +57,42 @@ ignore = [] | ||||
| defineConstant = { DEBUG = true } | ||||
| stubPath = "" | ||||
|  | ||||
| reportUnknownMemberType= false | ||||
| reportMissingImports = true | ||||
| reportMissingTypeStubs = false | ||||
| reportAny = false | ||||
| reportCallInDefaultInitializer = false | ||||
| # Type checking strictness | ||||
| typeCheckingMode = "strict"                        # Enables strict type checking mode | ||||
| reportPrivateUsage = "none" | ||||
| reportMissingTypeStubs = "none" | ||||
| reportUntypedFunctionDecorator = "error" | ||||
| reportUntypedClassDecorator = "error" | ||||
| reportUntypedBaseClass = "error" | ||||
| reportInvalidTypeVarUse = "error" | ||||
| reportUnnecessaryTypeIgnoreComment = "information" | ||||
| reportUnknownVariableType = "none" | ||||
| reportUnknownMemberType = "none" | ||||
| reportUnknownParameterType = "none" | ||||
|  | ||||
| pythonVersion = "3.9" | ||||
| pythonPlatform = "Linux" | ||||
| # Additional checks | ||||
| reportImplicitStringConcatenation = "error" | ||||
| reportInvalidStringEscapeSequence = "error" | ||||
| reportMissingImports = "error" | ||||
| reportMissingModuleSource = "error" | ||||
| reportOptionalCall = "error" | ||||
| reportOptionalIterable = "error" | ||||
| reportOptionalMemberAccess = "error" | ||||
| reportOptionalOperand = "error" | ||||
| reportOptionalSubscript = "error" | ||||
| reportTypedDictNotRequiredAccess = "error" | ||||
|  | ||||
| # Function return type checking | ||||
| reportIncompleteStub = "error" | ||||
| reportIncompatibleMethodOverride = "error" | ||||
| reportInvalidStubStatement = "error" | ||||
| reportInconsistentOverload = "error" | ||||
|  | ||||
| # Misc settings | ||||
| pythonVersion = "3.10"           # Matches your Python version from pyproject.toml | ||||
| strictListInference = true | ||||
| strictDictionaryInference = true | ||||
| strictSetInference = true | ||||
|  | ||||
|  | ||||
| [tool.isort] | ||||
|   | ||||
| @@ -28,10 +28,6 @@ export const JobSelector = ({ | ||||
|   const [popoverJob, setPopoverJob] = useState<Job | null>(null); | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     fetchJobs(setJobs, { chat: true }); | ||||
|   }, []); | ||||
|  | ||||
|   const handlePopoverOpen = ( | ||||
|     event: React.MouseEvent<HTMLElement>, | ||||
|     job: Job | ||||
| @@ -124,7 +120,9 @@ export const JobSelector = ({ | ||||
|                   fontStyle: "italic", | ||||
|                 }} | ||||
|               > | ||||
|                 {new Date(popoverJob.time_created).toLocaleString()} | ||||
|                 {popoverJob.time_created | ||||
|                   ? new Date(popoverJob.time_created).toLocaleString() | ||||
|                   : "Unknown"} | ||||
|               </Typography> | ||||
|             </div> | ||||
|           </Box> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import React from "react"; | ||||
| import { useAuth } from "../../../contexts/AuthContext"; | ||||
| import { Box, Drawer, Divider } from "@mui/material"; | ||||
| import { Box, Drawer } from "@mui/material"; | ||||
|  | ||||
| import { QuickSettings } from "../../nav/quick-settings"; | ||||
| import { NavItems } from "./nav-items/nav-items"; | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import TerminalIcon from "@mui/icons-material/Terminal"; | ||||
| import BarChart from "@mui/icons-material/BarChart"; | ||||
| import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; | ||||
| import { List } from "@mui/material"; | ||||
| import { Schedule } from "@mui/icons-material"; | ||||
|  | ||||
| const items = [ | ||||
|   { | ||||
| @@ -34,6 +35,11 @@ const items = [ | ||||
|     text: "View App Logs", | ||||
|     href: "/logs", | ||||
|   }, | ||||
|   { | ||||
|     icon: <Schedule />, | ||||
|     text: "Cron Jobs", | ||||
|     href: "/cron-jobs", | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| export const NavItems = () => { | ||||
|   | ||||
| @@ -175,7 +175,11 @@ export const JobQueue = ({ | ||||
|                       onDownload([row.id]); | ||||
|                     }} | ||||
|                     size="small" | ||||
|                     sx={{ minWidth: 0, padding: "4px 8px" }} | ||||
|                     sx={{ | ||||
|                       minWidth: 0, | ||||
|                       padding: "4px 8px", | ||||
|                       fontSize: "0.625rem", | ||||
|                     }} | ||||
|                   > | ||||
|                     Download | ||||
|                   </Button> | ||||
| @@ -184,7 +188,11 @@ export const JobQueue = ({ | ||||
|                       onNavigate(row.elements, row.url, row.job_options) | ||||
|                     } | ||||
|                     size="small" | ||||
|                     sx={{ minWidth: 0, padding: "4px 8px" }} | ||||
|                     sx={{ | ||||
|                       minWidth: 0, | ||||
|                       padding: "4px 8px", | ||||
|                       fontSize: "0.625rem", | ||||
|                     }} | ||||
|                   > | ||||
|                     Rerun | ||||
|                   </Button> | ||||
|   | ||||
							
								
								
									
										351
									
								
								src/components/pages/chat/chat.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								src/components/pages/chat/chat.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,351 @@ | ||||
| import React, { useEffect, useRef, useState } from "react"; | ||||
| import { | ||||
|   Box, | ||||
|   TextField, | ||||
|   Typography, | ||||
|   Paper, | ||||
|   useTheme, | ||||
|   IconButton, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { JobSelector } from "../../ai"; | ||||
| import { Job, Message } from "../../../types"; | ||||
| import { useSearchParams } from "next/navigation"; | ||||
| import { checkAI, fetchJob, fetchJobs, updateJob } from "../../../lib"; | ||||
| import SendIcon from "@mui/icons-material/Send"; | ||||
| import EditNoteIcon from "@mui/icons-material/EditNote"; | ||||
|  | ||||
| export const AI: React.FC = () => { | ||||
|   const theme = useTheme(); | ||||
|   const [currentMessage, setCurrentMessage] = useState<string>(""); | ||||
|   const [selectedJob, setSelectedJob] = useState<Job | null>(null); | ||||
|   const [messages, setMessages] = useState<Message[]>([]); | ||||
|   const [aiEnabled, setAiEnabled] = useState<boolean>(false); | ||||
|   const [jobs, setJobs] = useState<Job[]>([]); | ||||
|   const [thinking, setThinking] = useState<boolean>(false); | ||||
|  | ||||
|   const searchParams = useSearchParams(); | ||||
|  | ||||
|   const getJobFromParam = async () => { | ||||
|     const jobId = searchParams.get("job"); | ||||
|  | ||||
|     if (jobId) { | ||||
|       const job = await fetchJob(jobId); | ||||
|  | ||||
|       if (job.length) { | ||||
|         setSelectedJob(job[0]); | ||||
|         if (job[0].chat) { | ||||
|           setMessages(job[0].chat); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     checkAI(setAiEnabled); | ||||
|     getJobFromParam(); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedJob?.chat) { | ||||
|       setMessages(selectedJob?.chat); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setMessages([]); | ||||
|   }, [selectedJob]); | ||||
|  | ||||
|   const handleMessageSend = async (msg: string) => { | ||||
|     if (!selectedJob) { | ||||
|       throw Error("Job is not currently selected, but should be."); | ||||
|     } | ||||
|  | ||||
|     const updatedMessages = await sendMessage(msg); | ||||
|     await updateJob([selectedJob?.id], "chat", updatedMessages); | ||||
|   }; | ||||
|  | ||||
|   const sendMessage = async (msg: string) => { | ||||
|     const newMessage = { | ||||
|       content: msg, | ||||
|       role: "user", | ||||
|     }; | ||||
|  | ||||
|     setMessages((prevMessages) => [...prevMessages, newMessage]); | ||||
|     setCurrentMessage(""); | ||||
|     setThinking(true); | ||||
|  | ||||
|     const jobMessage = { | ||||
|       role: "system", | ||||
|       content: `Here is the content return from a scraping job: ${JSON.stringify( | ||||
|         selectedJob?.result | ||||
|       )} for the url: ${ | ||||
|         selectedJob?.url | ||||
|       }. The following messages will pertain to the content of the scraped job.`, | ||||
|     }; | ||||
|  | ||||
|     const response = await fetch("/api/ai", { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify({ | ||||
|         data: { messages: [jobMessage, ...messages, newMessage] }, | ||||
|       }), | ||||
|     }); | ||||
|  | ||||
|     const updatedMessages = [...messages, newMessage]; | ||||
|  | ||||
|     const reader = response.body?.getReader(); | ||||
|     const decoder = new TextDecoder("utf-8"); | ||||
|  | ||||
|     let aiResponse = ""; | ||||
|     if (reader) { | ||||
|       setThinking(false); | ||||
|       while (true) { | ||||
|         const { done, value } = await reader.read(); | ||||
|         if (done) break; | ||||
|         const chunk = decoder.decode(value, { stream: true }); | ||||
|         aiResponse += chunk; | ||||
|  | ||||
|         setMessages((prevMessages) => { | ||||
|           const lastMessage = prevMessages[prevMessages.length - 1]; | ||||
|           if (lastMessage && lastMessage.role === "assistant") { | ||||
|             return [ | ||||
|               ...prevMessages.slice(0, -1), | ||||
|               { ...lastMessage, content: aiResponse }, | ||||
|             ]; | ||||
|           } else { | ||||
|             return [ | ||||
|               ...prevMessages, | ||||
|               { | ||||
|                 content: aiResponse, | ||||
|                 role: "assistant", | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     return [...updatedMessages, { role: "assistant", content: aiResponse }]; | ||||
|   }; | ||||
|  | ||||
|   const handleNewChat = (selectedJob: Job) => { | ||||
|     updateJob([selectedJob.id], "chat", []); | ||||
|     setMessages([]); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     fetchJobs(setJobs); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|         height: "95vh", | ||||
|         maxWidth: "100%", | ||||
|         paddingLeft: 0, | ||||
|         paddingRight: 0, | ||||
|         borderRadius: "8px", | ||||
|         border: | ||||
|           theme.palette.mode === "light" ? "solid white" : "solid #4b5057", | ||||
|         boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)", | ||||
|         overflow: "hidden", | ||||
|       }} | ||||
|     > | ||||
|       {aiEnabled ? ( | ||||
|         <> | ||||
|           <Paper | ||||
|             elevation={3} | ||||
|             sx={{ | ||||
|               p: 2, | ||||
|               textAlign: "center", | ||||
|               fontSize: "1.2em", | ||||
|               position: "relative", | ||||
|               borderRadius: "8px 8px 0 0", | ||||
|               borderBottom: `2px solid ${theme.palette.divider}`, | ||||
|             }} | ||||
|           > | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 display: "flex", | ||||
|                 justifyContent: "center", | ||||
|                 alignItems: "center", | ||||
|                 position: "relative", | ||||
|                 padding: theme.spacing(1), | ||||
|               }} | ||||
|             > | ||||
|               <Typography | ||||
|                 sx={{ | ||||
|                   flex: 1, | ||||
|                   textAlign: "center", | ||||
|                 }} | ||||
|               > | ||||
|                 Chat with AI | ||||
|               </Typography> | ||||
|               <JobSelector | ||||
|                 selectedJob={selectedJob} | ||||
|                 setSelectedJob={setSelectedJob} | ||||
|                 setJobs={setJobs} | ||||
|                 jobs={jobs} | ||||
|                 sxProps={{ | ||||
|                   position: "absolute", | ||||
|                   right: theme.spacing(2), | ||||
|                   width: "25%", | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           </Paper> | ||||
|           <Box | ||||
|             sx={{ | ||||
|               position: "relative", | ||||
|               flex: 1, | ||||
|               p: 2, | ||||
|               overflowY: "auto", | ||||
|               maxHeight: "100%", | ||||
|             }} | ||||
|           > | ||||
|             {!selectedJob ? ( | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   position: "absolute", | ||||
|                   top: 0, | ||||
|                   left: "50%", | ||||
|                   transform: "translateX(-50%)", | ||||
|                   padding: 2, | ||||
|                   bgcolor: "rgba(128,128,128,0.1)", | ||||
|                   mt: 1, | ||||
|                   borderRadius: "8px", | ||||
|                 }} | ||||
|                 className="rounded-md" | ||||
|               > | ||||
|                 <Typography variant="body1"> | ||||
|                   Select a Job to Begin Chatting | ||||
|                 </Typography> | ||||
|               </Box> | ||||
|             ) : ( | ||||
|               <> | ||||
|                 {messages && | ||||
|                   messages.map((message, index) => ( | ||||
|                     <Box | ||||
|                       key={index} | ||||
|                       sx={{ | ||||
|                         my: 2, | ||||
|                         p: 1, | ||||
|                         borderRadius: "8px", | ||||
|                         boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", | ||||
|                         bgcolor: | ||||
|                           message.role === "user" | ||||
|                             ? theme.palette.UserMessage.main | ||||
|                             : theme.palette.AIMessage.main, | ||||
|                         marginLeft: message.role === "user" ? "auto" : "", | ||||
|                         maxWidth: "40%", | ||||
|                       }} | ||||
|                     > | ||||
|                       <Typography variant="body1" sx={{ color: "white" }}> | ||||
|                         {message.content} | ||||
|                       </Typography> | ||||
|                     </Box> | ||||
|                   ))} | ||||
|                 {thinking && ( | ||||
|                   <Box | ||||
|                     sx={{ | ||||
|                       width: "full", | ||||
|                       display: "flex", | ||||
|                       flexDirection: "column", | ||||
|                       justifyContent: "start", | ||||
|                     }} | ||||
|                   > | ||||
|                     <Typography | ||||
|                       sx={{ | ||||
|                         bgcolor: "rgba(128,128,128,0.1)", | ||||
|                         maxWidth: "20%", | ||||
|                         my: 2, | ||||
|                         p: 1, | ||||
|                         borderRadius: "8px", | ||||
|                         boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", | ||||
|                       }} | ||||
|                       variant="body1" | ||||
|                     > | ||||
|                       AI is thinking... | ||||
|                     </Typography> | ||||
|                   </Box> | ||||
|                 )} | ||||
|               </> | ||||
|             )} | ||||
|           </Box> | ||||
|           <Box | ||||
|             sx={{ | ||||
|               display: "flex", | ||||
|               p: 2, | ||||
|               borderTop: `1px solid ${theme.palette.divider}`, | ||||
|             }} | ||||
|           > | ||||
|             <Tooltip title="New Chat" placement="top"> | ||||
|               <IconButton | ||||
|                 disabled={!(messages.length > 0)} | ||||
|                 sx={{ marginRight: 2 }} | ||||
|                 size="medium" | ||||
|                 onClick={() => { | ||||
|                   if (!selectedJob) { | ||||
|                     throw new Error("Selected job must be present but isn't."); | ||||
|                   } | ||||
|                   handleNewChat(selectedJob); | ||||
|                 }} | ||||
|               > | ||||
|                 <EditNoteIcon fontSize="medium" /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <TextField | ||||
|               fullWidth | ||||
|               placeholder="Type your message here..." | ||||
|               disabled={!selectedJob} | ||||
|               value={currentMessage} | ||||
|               onChange={(e) => setCurrentMessage(e.target.value)} | ||||
|               onKeyDown={(e) => { | ||||
|                 if (e.key === "Enter") { | ||||
|                   handleMessageSend(currentMessage); | ||||
|                 } | ||||
|               }} | ||||
|               sx={{ borderRadius: "8px" }} | ||||
|             /> | ||||
|  | ||||
|             <Tooltip title="Send" placement="top"> | ||||
|               <IconButton | ||||
|                 color="primary" | ||||
|                 sx={{ ml: 2 }} | ||||
|                 disabled={!selectedJob} | ||||
|                 onClick={() => { | ||||
|                   handleMessageSend(currentMessage); | ||||
|                 }} | ||||
|               > | ||||
|                 <SendIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|           </Box> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <Box | ||||
|           bgcolor="background.default" | ||||
|           minHeight="100vh" | ||||
|           display="flex" | ||||
|           justifyContent="center" | ||||
|           alignItems="center" | ||||
|         > | ||||
|           <h4 | ||||
|             style={{ | ||||
|               color: "#fff", | ||||
|               padding: "20px", | ||||
|               borderRadius: "8px", | ||||
|               background: "rgba(0, 0, 0, 0.6)", | ||||
|               boxShadow: "0 4px 8px rgba(0, 0, 0, 0.2)", | ||||
|             }} | ||||
|           > | ||||
|             Must set either OPENAI_KEY or OLLAMA_MODEL to use AI features. | ||||
|           </h4> | ||||
|         </Box> | ||||
|       )} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,182 @@ | ||||
| import { Job } from "@/types"; | ||||
| import { | ||||
|   Button, | ||||
|   Dialog, | ||||
|   DialogTitle, | ||||
|   DialogContent, | ||||
|   TextField, | ||||
|   Snackbar, | ||||
|   Alert, | ||||
| } from "@mui/material"; | ||||
| import Cookies from "js-cookie"; | ||||
| import { useState } from "react"; | ||||
|  | ||||
| export type CreateCronJobsProps = { | ||||
|   availableJobs: Job[]; | ||||
|   user: any; | ||||
| }; | ||||
|  | ||||
| export const CreateCronJobs = ({ | ||||
|   availableJobs, | ||||
|   user, | ||||
| }: CreateCronJobsProps) => { | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button | ||||
|         variant="contained" | ||||
|         color="primary" | ||||
|         onClick={() => setOpen(true)} | ||||
|         sx={{ borderRadius: 2 }} | ||||
|       > | ||||
|         Create Cron Job | ||||
|       </Button> | ||||
|       <CreateCronJobDialog | ||||
|         open={open} | ||||
|         onClose={() => setOpen(false)} | ||||
|         availableJobs={availableJobs} | ||||
|         user={user} | ||||
|       /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const CreateCronJobDialog = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   availableJobs, | ||||
|   user, | ||||
| }: { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   availableJobs: Job[]; | ||||
|   user: any; | ||||
| }) => { | ||||
|   const [cronExpression, setCronExpression] = useState(""); | ||||
|   const [jobId, setJobId] = useState(""); | ||||
|   const [successOpen, setSuccessOpen] = useState(false); | ||||
|   const [isSubmitting, setIsSubmitting] = useState(false); | ||||
|   const [error, setError] = useState(""); | ||||
|  | ||||
|   const handleSubmit = async () => { | ||||
|     if (!cronExpression || !jobId) { | ||||
|       setError("Please fill in all fields"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setIsSubmitting(true); | ||||
|     const token = Cookies.get("token"); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch("/api/schedule-cron-job", { | ||||
|         method: "POST", | ||||
|         headers: { | ||||
|           "Content-Type": "application/json", | ||||
|           Authorization: `Bearer ${token}`, | ||||
|         }, | ||||
|         body: JSON.stringify({ | ||||
|           data: { | ||||
|             cron_expression: cronExpression, | ||||
|             job_id: jobId, | ||||
|             user_email: user.email, | ||||
|           }, | ||||
|         }), | ||||
|       }); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         throw new Error("Failed to schedule job"); | ||||
|       } | ||||
|  | ||||
|       setSuccessOpen(true); | ||||
|       setCronExpression(""); | ||||
|       setJobId(""); | ||||
|       setTimeout(() => { | ||||
|         onClose(); | ||||
|       }, 1500); | ||||
|       window.location.reload(); | ||||
|     } catch (error) { | ||||
|       console.error(error); | ||||
|       setError("Failed to create cron job"); | ||||
|     } finally { | ||||
|       setIsSubmitting(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setSuccessOpen(false); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Dialog | ||||
|         open={open} | ||||
|         onClose={onClose} | ||||
|         PaperProps={{ | ||||
|           sx: { borderRadius: 2, minWidth: "400px" }, | ||||
|         }} | ||||
|       > | ||||
|         <DialogTitle sx={{ fontWeight: 500 }}>Create Cron Job</DialogTitle> | ||||
|         <DialogContent> | ||||
|           <div className="flex flex-col gap-1 mt0"> | ||||
|             <TextField | ||||
|               label="Cron Expression" | ||||
|               fullWidth | ||||
|               value={cronExpression} | ||||
|               onChange={(e) => setCronExpression(e.target.value)} | ||||
|               variant="outlined" | ||||
|               placeholder="* * * * *" | ||||
|               margin="normal" | ||||
|               helperText="Format: minute hour day month day-of-week" | ||||
|             /> | ||||
|  | ||||
|             <TextField | ||||
|               label="Job ID" | ||||
|               fullWidth | ||||
|               value={jobId} | ||||
|               onChange={(e) => setJobId(e.target.value)} | ||||
|               variant="outlined" | ||||
|               margin="normal" | ||||
|             /> | ||||
|  | ||||
|             {error && ( | ||||
|               <Alert severity="error" sx={{ mt: 2 }}> | ||||
|                 {error} | ||||
|               </Alert> | ||||
|             )} | ||||
|  | ||||
|             <div className="flex justify-end gap-2 mt-4"> | ||||
|               <Button | ||||
|                 variant="outlined" | ||||
|                 onClick={onClose} | ||||
|                 sx={{ borderRadius: 2 }} | ||||
|               > | ||||
|                 Cancel | ||||
|               </Button> | ||||
|               <Button | ||||
|                 variant="contained" | ||||
|                 color="primary" | ||||
|                 onClick={handleSubmit} | ||||
|                 disabled={isSubmitting} | ||||
|                 sx={{ borderRadius: 2 }} | ||||
|               > | ||||
|                 {isSubmitting ? "Submitting..." : "Create Job"} | ||||
|               </Button> | ||||
|             </div> | ||||
|           </div> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|  | ||||
|       <Snackbar | ||||
|         open={successOpen} | ||||
|         autoHideDuration={4000} | ||||
|         onClose={handleClose} | ||||
|         anchorOrigin={{ vertical: "bottom", horizontal: "right" }} | ||||
|       > | ||||
|         <Alert onClose={handleClose} severity="success" sx={{ width: "100%" }}> | ||||
|           Cron job created successfully! | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										1
									
								
								src/components/pages/cron-jobs/create-cron-jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/pages/cron-jobs/create-cron-jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * from "./create-cron-jobs"; | ||||
							
								
								
									
										0
									
								
								src/components/pages/cron-jobs/cron-jobs.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/components/pages/cron-jobs/cron-jobs.module.css
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										104
									
								
								src/components/pages/cron-jobs/cron-jobs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/components/pages/cron-jobs/cron-jobs.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| import { Job, CronJob } from "@/types/job"; | ||||
| import { useState, useEffect } from "react"; | ||||
| import { CreateCronJobs } from "./create-cron-jobs"; | ||||
| import { | ||||
|   Table, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   TableCell, | ||||
|   TableBody, | ||||
|   Button, | ||||
|   Box, | ||||
|   Typography, | ||||
| } from "@mui/material"; | ||||
| import Cookies from "js-cookie"; | ||||
|  | ||||
| export type CronJobsProps = { | ||||
|   initialJobs: Job[]; | ||||
|   initialCronJobs: CronJob[]; | ||||
|   initialUser: any; | ||||
| }; | ||||
|  | ||||
| export const CronJobs = ({ | ||||
|   initialJobs, | ||||
|   initialCronJobs, | ||||
|   initialUser, | ||||
| }: CronJobsProps) => { | ||||
|   const [jobs, setJobs] = useState<Job[]>(initialJobs); | ||||
|   const [cronJobs, setCronJobs] = useState<CronJob[]>(initialCronJobs); | ||||
|   const [user, setUser] = useState<any>(initialUser); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setJobs(initialJobs); | ||||
|     setCronJobs(initialCronJobs); | ||||
|     setUser(initialUser); | ||||
|   }, [initialJobs, initialCronJobs, initialUser]); | ||||
|  | ||||
|   const handleDeleteCronJob = async (id: string) => { | ||||
|     const token = Cookies.get("token"); | ||||
|     const response = await fetch("/api/delete-cron-job", { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|         Authorization: `Bearer ${token}`, | ||||
|       }, | ||||
|       body: JSON.stringify({ data: { id, user_email: user.email } }), | ||||
|     }); | ||||
|  | ||||
|     if (response.ok) { | ||||
|       console.log("Cron job deleted successfully"); | ||||
|       setCronJobs(cronJobs.filter((cronJob) => cronJob.id !== id)); | ||||
|     } else { | ||||
|       console.error("Failed to delete cron job"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!user) { | ||||
|     return ( | ||||
|       <Box> | ||||
|         <Typography variant="h6"> | ||||
|           Please login to view your cron jobs | ||||
|         </Typography> | ||||
|       </Box> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <CreateCronJobs availableJobs={jobs} user={user} /> | ||||
|  | ||||
|       <Table> | ||||
|         <TableHead> | ||||
|           <TableRow> | ||||
|             <TableCell>Cron Expression</TableCell> | ||||
|             <TableCell>Job ID</TableCell> | ||||
|             <TableCell>User Email</TableCell> | ||||
|             <TableCell>Created At</TableCell> | ||||
|             <TableCell>Updated At</TableCell> | ||||
|             <TableCell>Actions</TableCell> | ||||
|           </TableRow> | ||||
|         </TableHead> | ||||
|         <TableBody> | ||||
|           {cronJobs.map((cronJob) => ( | ||||
|             <TableRow key={cronJob.id}> | ||||
|               <TableCell>{cronJob.cron_expression}</TableCell> | ||||
|               <TableCell>{cronJob.job_id}</TableCell> | ||||
|               <TableCell>{cronJob.user_email}</TableCell> | ||||
|               <TableCell> | ||||
|                 {new Date(cronJob.time_created).toLocaleString()} | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 {new Date(cronJob.time_updated).toLocaleString()} | ||||
|               </TableCell> | ||||
|               <TableCell> | ||||
|                 <Button onClick={() => handleDeleteCronJob(cronJob.id)}> | ||||
|                   Delete | ||||
|                 </Button> | ||||
|               </TableCell> | ||||
|             </TableRow> | ||||
|           ))} | ||||
|         </TableBody> | ||||
|       </Table> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										62
									
								
								src/components/pages/cron-jobs/get-server-side-props.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/components/pages/cron-jobs/get-server-side-props.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import axios from "axios"; | ||||
| import { GetServerSideProps } from "next"; | ||||
| import { parseCookies } from "nookies"; | ||||
| import { CronJob, Job } from "../../../types"; | ||||
|  | ||||
| export const getServerSideProps: GetServerSideProps = async (context) => { | ||||
|   const { req } = context; | ||||
|   const cookies = parseCookies({ req }); | ||||
|   const token = cookies.token; | ||||
|   let user = null; | ||||
|   let initialJobs: Job[] = []; | ||||
|   let initialCronJobs: CronJob[] = []; | ||||
|   if (token) { | ||||
|     try { | ||||
|       const userResponse = await axios.get( | ||||
|         `${process.env.NEXT_PUBLIC_API_URL}/api/auth/users/me`, | ||||
|         { | ||||
|           headers: { Authorization: `Bearer ${token}` }, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       user = userResponse.data; | ||||
|  | ||||
|       const jobsResponse = await fetch( | ||||
|         `${process.env.NEXT_PUBLIC_API_URL}/api/retrieve-scrape-jobs`, | ||||
|         { | ||||
|           method: "POST", | ||||
|           body: JSON.stringify({ user: user.email }), | ||||
|           headers: { | ||||
|             "content-type": "application/json", | ||||
|             Authorization: `Bearer ${token}`, | ||||
|           }, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       initialJobs = await jobsResponse.json(); | ||||
|       console.log(initialJobs); | ||||
|  | ||||
|       const cronJobsResponse = await fetch( | ||||
|         `${process.env.NEXT_PUBLIC_API_URL}/api/cron-jobs`, | ||||
|         { | ||||
|           headers: { | ||||
|             "content-type": "application/json", | ||||
|             Authorization: `Bearer ${token}`, | ||||
|           }, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       initialCronJobs = await cronJobsResponse.json(); | ||||
|     } catch (error) { | ||||
|       console.error("Error fetching user or jobs:", error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     props: { | ||||
|       initialJobs, | ||||
|       initialUser: user, | ||||
|       initialCronJobs, | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
							
								
								
									
										1
									
								
								src/components/pages/cron-jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/components/pages/cron-jobs/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export { CronJobs } from "./cron-jobs"; | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { RawJobOptions } from "@/types/job"; | ||||
| import { Box, FormControlLabel, Checkbox, TextField } from "@mui/material"; | ||||
| import { Dispatch, SetStateAction } from "react"; | ||||
| import { useJobSubmitterProvider } from "../provider"; | ||||
|  | ||||
| export type JobSubmitterOptionsProps = { | ||||
|   jobOptions: RawJobOptions; | ||||
| @@ -43,6 +42,13 @@ export const JobSubmitterOptions = ({ | ||||
|     })); | ||||
|   }; | ||||
|  | ||||
|   const handleCollectMediaChange = () => { | ||||
|     setJobOptions((prevJobOptions) => ({ | ||||
|       ...prevJobOptions, | ||||
|       collect_media: !prevJobOptions.collect_media, | ||||
|     })); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Box bgcolor="background.paper" className="flex flex-col mb-2 rounded-md"> | ||||
|       <div id="options" className="p-2 flex flex-row space-x-2"> | ||||
| @@ -95,6 +101,15 @@ export const JobSubmitterOptions = ({ | ||||
|             /> | ||||
|           } | ||||
|         ></FormControlLabel> | ||||
|         <FormControlLabel | ||||
|           label="Collect Media" | ||||
|           control={ | ||||
|             <Checkbox | ||||
|               checked={jobOptions.collect_media} | ||||
|               onChange={handleCollectMediaChange} | ||||
|             /> | ||||
|           } | ||||
|         /> | ||||
|       </div> | ||||
|       {customJSONSelected ? ( | ||||
|         <div id="custom-json" className="pl-2 pr-2 pb-2"> | ||||
|   | ||||
| @@ -15,6 +15,7 @@ const initialJobOptions: RawJobOptions = { | ||||
|   multi_page_scrape: false, | ||||
|   custom_headers: null, | ||||
|   proxies: null, | ||||
|   collect_media: false, | ||||
| }; | ||||
|  | ||||
| export const JobSubmitter = () => { | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import React, { createContext, useContext, useState, useEffect } from "react"; | ||||
| import axios from "axios"; | ||||
| import { Constants } from "../lib"; | ||||
| import Cookies from "js-cookie"; | ||||
|  | ||||
| interface AuthContextProps { | ||||
| @@ -55,6 +54,7 @@ export const AuthProvider: React.FC<AuthProps> = ({ children }) => { | ||||
|     const userResponse = await axios.get(`/api/me`, { | ||||
|       headers: { Authorization: `Bearer ${response.data.access_token}` }, | ||||
|     }); | ||||
|  | ||||
|     setUser(userResponse.data); | ||||
|     setIsAuthenticated(true); | ||||
|   }; | ||||
|   | ||||
| @@ -11,11 +11,11 @@ export const parseJobOptions = ( | ||||
| ) => { | ||||
|   if (job_options) { | ||||
|     const jsonOptions = JSON.parse(job_options as string); | ||||
|     console.log(jsonOptions); | ||||
|     const newJobOptions: RawJobOptions = { | ||||
|       multi_page_scrape: false, | ||||
|       custom_headers: null, | ||||
|       proxies: null, | ||||
|       collect_media: false, | ||||
|     }; | ||||
|  | ||||
|     if ( | ||||
|   | ||||
| @@ -48,14 +48,14 @@ export const checkAI = async ( | ||||
| ) => { | ||||
|   const token = Cookies.get("token"); | ||||
|   try { | ||||
|     const response = await fetch("/api/ai/check", { | ||||
|     const response = await fetch("/api/check", { | ||||
|       headers: { | ||||
|         "content-type": "application/json", | ||||
|         Authorization: `Bearer ${token}`, | ||||
|       }, | ||||
|     }); | ||||
|     const data = await response.json(); | ||||
|     setAiEnabled(data); | ||||
|     setAiEnabled(data.ai_enabled); | ||||
|   } catch (error) { | ||||
|     console.error("Error fetching jobs:", error); | ||||
|     throw error; | ||||
|   | ||||
| @@ -17,12 +17,21 @@ export default async function handler( | ||||
|       } | ||||
|     ); | ||||
| 
 | ||||
|     const checksResponse = await fetch( | ||||
|       `${global.process.env.NEXT_PUBLIC_API_URL}/api/auth/check`, | ||||
|       { | ||||
|         method: "GET", | ||||
|         headers, | ||||
|       } | ||||
|     ); | ||||
| 
 | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Error: ${response.statusText}`); | ||||
|     } | ||||
| 
 | ||||
|     const result = await response.json(); | ||||
|     res.status(200).json(result); | ||||
|     const checksResult = await checksResponse.json(); | ||||
|     res.status(200).json({ ...result, ...checksResult }); | ||||
|   } catch (error) { | ||||
|     console.error("Error submitting scrape job:", error); | ||||
|     res.status(500).json({ error: "Internal Server Error" }); | ||||
							
								
								
									
										39
									
								
								src/pages/api/delete-cron-job.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/pages/api/delete-cron-job.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { NextApiRequest, NextApiResponse } from "next"; | ||||
|  | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse | ||||
| ) { | ||||
|   if (req.method === "POST") { | ||||
|     const { data } = req.body; | ||||
|     console.log("Data", data); | ||||
|  | ||||
|     const headers = new Headers(); | ||||
|     headers.set("content-type", "application/json"); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch( | ||||
|         `${global.process.env.NEXT_PUBLIC_API_URL}/api/delete-cron-job`, | ||||
|         { | ||||
|           method: "POST", | ||||
|           headers, | ||||
|           body: JSON.stringify(data), | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         console.error(response); | ||||
|         throw new Error(`Error: ${response.statusText}`); | ||||
|       } | ||||
|  | ||||
|       const result = await response.json(); | ||||
|       res.status(200).json(result); | ||||
|     } catch (error) { | ||||
|       console.error("Error deleting cron job:", error); | ||||
|       res.status(500).json({ error: "Internal Server Error" }); | ||||
|     } | ||||
|   } else { | ||||
|     res.setHeader("Allow", ["POST"]); | ||||
|     res.status(405).end(`Method ${req.method} Not Allowed`); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										39
									
								
								src/pages/api/schedule-cron-job.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/pages/api/schedule-cron-job.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { NextApiRequest, NextApiResponse } from "next"; | ||||
|  | ||||
| export default async function handler( | ||||
|   req: NextApiRequest, | ||||
|   res: NextApiResponse | ||||
| ) { | ||||
|   if (req.method === "POST") { | ||||
|     const { data } = req.body; | ||||
|     console.log("Data", data); | ||||
|  | ||||
|     const headers = new Headers(); | ||||
|     headers.set("content-type", "application/json"); | ||||
|  | ||||
|     try { | ||||
|       const response = await fetch( | ||||
|         `${global.process.env.NEXT_PUBLIC_API_URL}/api/schedule-cron-job`, | ||||
|         { | ||||
|           method: "POST", | ||||
|           headers, | ||||
|           body: JSON.stringify(data), | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       if (!response.ok) { | ||||
|         console.error(response); | ||||
|         throw new Error(`Error: ${response.statusText}`); | ||||
|       } | ||||
|  | ||||
|       const result = await response.json(); | ||||
|       res.status(200).json(result); | ||||
|     } catch (error) { | ||||
|       console.error("Error scheduling cron job:", error); | ||||
|       res.status(500).json({ error: "Internal Server Error" }); | ||||
|     } | ||||
|   } else { | ||||
|     res.setHeader("Allow", ["POST"]); | ||||
|     res.status(405).end(`Method ${req.method} Not Allowed`); | ||||
|   } | ||||
| } | ||||
| @@ -1,348 +1 @@ | ||||
| import React, { useEffect, useRef, useState } from "react"; | ||||
| import { | ||||
|   Box, | ||||
|   TextField, | ||||
|   Typography, | ||||
|   Paper, | ||||
|   useTheme, | ||||
|   IconButton, | ||||
|   Tooltip, | ||||
| } from "@mui/material"; | ||||
| import { JobSelector } from "../components/ai"; | ||||
| import { Job, Message } from "../types"; | ||||
| import { useSearchParams } from "next/navigation"; | ||||
| import { checkAI, fetchJob, fetchJobs, updateJob } from "../lib"; | ||||
| import SendIcon from "@mui/icons-material/Send"; | ||||
| import EditNoteIcon from "@mui/icons-material/EditNote"; | ||||
|  | ||||
| const AI: React.FC = () => { | ||||
|   const theme = useTheme(); | ||||
|   const [currentMessage, setCurrentMessage] = useState<string>(""); | ||||
|   const [selectedJob, setSelectedJob] = useState<Job | null>(null); | ||||
|   const [messages, setMessages] = useState<Message[]>([]); | ||||
|   const [aiEnabled, setAiEnabled] = useState<boolean>(false); | ||||
|   const [jobs, setJobs] = useState<Job[]>([]); | ||||
|   const [thinking, setThinking] = useState<boolean>(false); | ||||
|  | ||||
|   const searchParams = useSearchParams(); | ||||
|  | ||||
|   const getJobFromParam = async () => { | ||||
|     const jobId = searchParams.get("job"); | ||||
|     if (jobId) { | ||||
|       const job = await fetchJob(jobId); | ||||
|       if (job.length) { | ||||
|         setSelectedJob(job[0]); | ||||
|         if (job[0].chat) { | ||||
|           setMessages(job[0].chat); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     checkAI(setAiEnabled); | ||||
|     getJobFromParam(); | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedJob?.chat) { | ||||
|       setMessages(selectedJob?.chat); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setMessages([]); | ||||
|   }, [selectedJob]); | ||||
|  | ||||
|   const handleMessageSend = async (msg: string) => { | ||||
|     if (!selectedJob) { | ||||
|       throw Error("Job is not currently selected, but should be."); | ||||
|     } | ||||
|  | ||||
|     const updatedMessages = await sendMessage(msg); | ||||
|     await updateJob([selectedJob?.id], "chat", updatedMessages); | ||||
|   }; | ||||
|  | ||||
|   const sendMessage = async (msg: string) => { | ||||
|     const newMessage = { | ||||
|       content: msg, | ||||
|       role: "user", | ||||
|     }; | ||||
|  | ||||
|     setMessages((prevMessages) => [...prevMessages, newMessage]); | ||||
|     setCurrentMessage(""); | ||||
|     setThinking(true); | ||||
|  | ||||
|     const jobMessage = { | ||||
|       role: "system", | ||||
|       content: `Here is the content return from a scraping job: ${JSON.stringify( | ||||
|         selectedJob?.result | ||||
|       )} for the url: ${ | ||||
|         selectedJob?.url | ||||
|       }. The following messages will pertain to the content of the scraped job.`, | ||||
|     }; | ||||
|  | ||||
|     const response = await fetch("/api/ai", { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify({ | ||||
|         data: { messages: [jobMessage, ...messages, newMessage] }, | ||||
|       }), | ||||
|     }); | ||||
|  | ||||
|     const updatedMessages = [...messages, newMessage]; | ||||
|  | ||||
|     const reader = response.body?.getReader(); | ||||
|     const decoder = new TextDecoder("utf-8"); | ||||
|  | ||||
|     let aiResponse = ""; | ||||
|     if (reader) { | ||||
|       setThinking(false); | ||||
|       while (true) { | ||||
|         const { done, value } = await reader.read(); | ||||
|         if (done) break; | ||||
|         const chunk = decoder.decode(value, { stream: true }); | ||||
|         aiResponse += chunk; | ||||
|  | ||||
|         setMessages((prevMessages) => { | ||||
|           const lastMessage = prevMessages[prevMessages.length - 1]; | ||||
|           if (lastMessage && lastMessage.role === "assistant") { | ||||
|             return [ | ||||
|               ...prevMessages.slice(0, -1), | ||||
|               { ...lastMessage, content: aiResponse }, | ||||
|             ]; | ||||
|           } else { | ||||
|             return [ | ||||
|               ...prevMessages, | ||||
|               { | ||||
|                 content: aiResponse, | ||||
|                 role: "assistant", | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     return [...updatedMessages, { role: "assistant", content: aiResponse }]; | ||||
|   }; | ||||
|  | ||||
|   const handleNewChat = (selectedJob: Job) => { | ||||
|     updateJob([selectedJob.id], "chat", []); | ||||
|     setMessages([]); | ||||
|     fetchJobs(setJobs, { chat: true }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|         height: "95vh", | ||||
|         maxWidth: "100%", | ||||
|         paddingLeft: 0, | ||||
|         paddingRight: 0, | ||||
|         borderRadius: "8px", | ||||
|         border: | ||||
|           theme.palette.mode === "light" ? "solid white" : "solid #4b5057", | ||||
|         boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)", | ||||
|         overflow: "hidden", | ||||
|       }} | ||||
|     > | ||||
|       {aiEnabled ? ( | ||||
|         <> | ||||
|           <Paper | ||||
|             elevation={3} | ||||
|             sx={{ | ||||
|               p: 2, | ||||
|               textAlign: "center", | ||||
|               fontSize: "1.2em", | ||||
|               position: "relative", | ||||
|               borderRadius: "8px 8px 0 0", | ||||
|               borderBottom: `2px solid ${theme.palette.divider}`, | ||||
|             }} | ||||
|           > | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 display: "flex", | ||||
|                 justifyContent: "center", | ||||
|                 alignItems: "center", | ||||
|                 position: "relative", | ||||
|                 padding: theme.spacing(1), | ||||
|               }} | ||||
|             > | ||||
|               <Typography | ||||
|                 sx={{ | ||||
|                   flex: 1, | ||||
|                   textAlign: "center", | ||||
|                 }} | ||||
|               > | ||||
|                 Chat with AI | ||||
|               </Typography> | ||||
|               <JobSelector | ||||
|                 selectedJob={selectedJob} | ||||
|                 setSelectedJob={setSelectedJob} | ||||
|                 setJobs={setJobs} | ||||
|                 jobs={jobs} | ||||
|                 sxProps={{ | ||||
|                   position: "absolute", | ||||
|                   right: theme.spacing(2), | ||||
|                   width: "25%", | ||||
|                 }} | ||||
|               /> | ||||
|             </Box> | ||||
|           </Paper> | ||||
|           <Box | ||||
|             sx={{ | ||||
|               position: "relative", | ||||
|               flex: 1, | ||||
|               p: 2, | ||||
|               overflowY: "auto", | ||||
|               maxHeight: "100%", | ||||
|             }} | ||||
|           > | ||||
|             {!selectedJob ? ( | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   position: "absolute", | ||||
|                   top: 0, | ||||
|                   left: "50%", | ||||
|                   transform: "translateX(-50%)", | ||||
|                   padding: 2, | ||||
|                   bgcolor: "rgba(128,128,128,0.1)", | ||||
|                   mt: 1, | ||||
|                   borderRadius: "8px", | ||||
|                 }} | ||||
|                 className="rounded-md" | ||||
|               > | ||||
|                 <Typography variant="body1"> | ||||
|                   Select a Job to Begin Chatting | ||||
|                 </Typography> | ||||
|               </Box> | ||||
|             ) : ( | ||||
|               <> | ||||
|                 {messages && | ||||
|                   messages.map((message, index) => ( | ||||
|                     <Box | ||||
|                       key={index} | ||||
|                       sx={{ | ||||
|                         my: 2, | ||||
|                         p: 1, | ||||
|                         borderRadius: "8px", | ||||
|                         boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", | ||||
|                         bgcolor: | ||||
|                           message.role === "user" | ||||
|                             ? theme.palette.UserMessage.main | ||||
|                             : theme.palette.AIMessage.main, | ||||
|                         marginLeft: message.role === "user" ? "auto" : "", | ||||
|                         maxWidth: "40%", | ||||
|                       }} | ||||
|                     > | ||||
|                       <Typography variant="body1" sx={{ color: "white" }}> | ||||
|                         {message.content} | ||||
|                       </Typography> | ||||
|                     </Box> | ||||
|                   ))} | ||||
|                 {thinking && ( | ||||
|                   <Box | ||||
|                     sx={{ | ||||
|                       width: "full", | ||||
|                       display: "flex", | ||||
|                       flexDirection: "column", | ||||
|                       justifyContent: "start", | ||||
|                     }} | ||||
|                   > | ||||
|                     <Typography | ||||
|                       sx={{ | ||||
|                         bgcolor: "rgba(128,128,128,0.1)", | ||||
|                         maxWidth: "20%", | ||||
|                         my: 2, | ||||
|                         p: 1, | ||||
|                         borderRadius: "8px", | ||||
|                         boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", | ||||
|                       }} | ||||
|                       variant="body1" | ||||
|                     > | ||||
|                       AI is thinking... | ||||
|                     </Typography> | ||||
|                   </Box> | ||||
|                 )} | ||||
|               </> | ||||
|             )} | ||||
|           </Box> | ||||
|           <Box | ||||
|             sx={{ | ||||
|               display: "flex", | ||||
|               p: 2, | ||||
|               borderTop: `1px solid ${theme.palette.divider}`, | ||||
|             }} | ||||
|           > | ||||
|             <Tooltip title="New Chat" placement="top"> | ||||
|               <IconButton | ||||
|                 disabled={!(messages.length > 0)} | ||||
|                 sx={{ marginRight: 2 }} | ||||
|                 size="medium" | ||||
|                 onClick={() => { | ||||
|                   if (!selectedJob) { | ||||
|                     throw new Error("Selected job must be present but isn't."); | ||||
|                   } | ||||
|                   handleNewChat(selectedJob); | ||||
|                 }} | ||||
|               > | ||||
|                 <EditNoteIcon fontSize="medium" /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|             <TextField | ||||
|               fullWidth | ||||
|               placeholder="Type your message here..." | ||||
|               disabled={!selectedJob} | ||||
|               value={currentMessage} | ||||
|               onChange={(e) => setCurrentMessage(e.target.value)} | ||||
|               onKeyDown={(e) => { | ||||
|                 if (e.key === "Enter") { | ||||
|                   handleMessageSend(currentMessage); | ||||
|                 } | ||||
|               }} | ||||
|               sx={{ borderRadius: "8px" }} | ||||
|             /> | ||||
|  | ||||
|             <Tooltip title="Send" placement="top"> | ||||
|               <IconButton | ||||
|                 color="primary" | ||||
|                 sx={{ ml: 2 }} | ||||
|                 disabled={!selectedJob} | ||||
|                 onClick={() => { | ||||
|                   handleMessageSend(currentMessage); | ||||
|                 }} | ||||
|               > | ||||
|                 <SendIcon /> | ||||
|               </IconButton> | ||||
|             </Tooltip> | ||||
|           </Box> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <Box | ||||
|           bgcolor="background.default" | ||||
|           minHeight="100vh" | ||||
|           display="flex" | ||||
|           justifyContent="center" | ||||
|           alignItems="center" | ||||
|         > | ||||
|           <h4 | ||||
|             style={{ | ||||
|               color: "#fff", | ||||
|               padding: "20px", | ||||
|               borderRadius: "8px", | ||||
|               background: "rgba(0, 0, 0, 0.6)", | ||||
|               boxShadow: "0 4px 8px rgba(0, 0, 0, 0.2)", | ||||
|             }} | ||||
|           > | ||||
|             Must set either OPENAI_KEY or OLLAMA_MODEL to use AI features. | ||||
|           </h4> | ||||
|         </Box> | ||||
|       )} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default AI; | ||||
| export { AI as default } from "../components/pages/chat/chat"; | ||||
|   | ||||
							
								
								
									
										4
									
								
								src/pages/cron-jobs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/pages/cron-jobs.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| import { CronJobs } from "../components/pages/cron-jobs"; | ||||
| import { getServerSideProps } from "../components/pages/cron-jobs/get-server-side-props"; | ||||
| export { getServerSideProps }; | ||||
| export default CronJobs; | ||||
| @@ -1,6 +1,6 @@ | ||||
| "use client"; | ||||
|  | ||||
| import React, { useState } from "react"; | ||||
| import React, { useEffect, useState } from "react"; | ||||
| import axios from "axios"; | ||||
| import { Button, TextField, Typography, Box } from "@mui/material"; | ||||
| import { useTheme } from "@mui/material/styles"; | ||||
| @@ -18,7 +18,16 @@ const AuthForm: React.FC = () => { | ||||
|   const theme = useTheme(); | ||||
|   const router = useRouter(); | ||||
|   const { login } = useAuth(); | ||||
|   const [registrationEnabled, setRegistrationEnabled] = useState<boolean>(true); | ||||
|  | ||||
|   const checkRegistrationEnabled = async () => { | ||||
|     const response = await axios.get(`/api/check`); | ||||
|     setRegistrationEnabled(response.data.registration); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     checkRegistrationEnabled(); | ||||
|   }, []); | ||||
|   const handleSubmit = async (event: React.FormEvent) => { | ||||
|     event.preventDefault(); | ||||
|     try { | ||||
| @@ -124,9 +133,37 @@ const AuthForm: React.FC = () => { | ||||
|           > | ||||
|             {mode.charAt(0).toUpperCase() + mode.slice(1)} | ||||
|           </Button> | ||||
|           <Button onClick={toggleMode} fullWidth variant="text" color="primary"> | ||||
|             {mode === "login" ? "No Account? Sign up" : "Login"} | ||||
|           </Button> | ||||
|           {registrationEnabled && ( | ||||
|             <Button | ||||
|               onClick={toggleMode} | ||||
|               fullWidth | ||||
|               variant="text" | ||||
|               color="primary" | ||||
|             > | ||||
|               {mode === "login" ? "No Account? Sign up" : "Login"} | ||||
|             </Button> | ||||
|           )} | ||||
|  | ||||
|           {!registrationEnabled && ( | ||||
|             <div | ||||
|               style={{ | ||||
|                 marginTop: 10, | ||||
|                 width: "100%", | ||||
|                 textAlign: "center", | ||||
|                 border: "1px solid #ccc", | ||||
|                 backgroundColor: "#f8f8f8", | ||||
|                 padding: 8, | ||||
|                 borderRadius: 4, | ||||
|                 display: "flex", | ||||
|                 justifyContent: "center", | ||||
|                 alignItems: "center", | ||||
|               }} | ||||
|             > | ||||
|               <Typography variant="body2" color="text"> | ||||
|                 Registration has been disabled | ||||
|               </Typography> | ||||
|             </div> | ||||
|           )} | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   | ||||
| @@ -19,6 +19,7 @@ export const submitJob = async ( | ||||
|         time_created: new Date().toISOString(), | ||||
|         job_options: { | ||||
|           ...jobOptions, | ||||
|           collect_media: jobOptions.collect_media || false, | ||||
|           custom_headers: customHeaders || {}, | ||||
|           proxies: jobOptions.proxies ? jobOptions.proxies.split(",") : [], | ||||
|           site_map: siteMap, | ||||
|   | ||||
| @@ -23,6 +23,7 @@ export type RawJobOptions = { | ||||
|   multi_page_scrape: boolean; | ||||
|   custom_headers: string | null; | ||||
|   proxies: string | null; | ||||
|   collect_media: boolean; | ||||
| }; | ||||
|  | ||||
| export type ActionOption = "click" | "input"; | ||||
| @@ -38,3 +39,12 @@ export type Action = { | ||||
| export type SiteMap = { | ||||
|   actions: Action[]; | ||||
| }; | ||||
|  | ||||
| export type CronJob = { | ||||
|   id: string; | ||||
|   user_email: string; | ||||
|   job_id: string; | ||||
|   cron_expression: string; | ||||
|   time_created: Date; | ||||
|   time_updated: Date; | ||||
| }; | ||||
|   | ||||
| @@ -1,30 +0,0 @@ | ||||
| # LOCAL | ||||
| from freeaskinternet.models.Models import ( | ||||
|     ModelCard, | ||||
|     ModelList, | ||||
|     SearchItem, | ||||
|     SearchResp, | ||||
|     ChatMessage, | ||||
|     DeltaMessage, | ||||
|     QueryRequest, | ||||
|     SearchItemList, | ||||
|     ChatCompletionRequest, | ||||
|     ChatCompletionResponse, | ||||
|     ChatCompletionResponseChoice, | ||||
|     ChatCompletionResponseStreamChoice, | ||||
| ) | ||||
|  | ||||
| __all__ = [ | ||||
|     "ModelCard", | ||||
|     "ModelList", | ||||
|     "ChatMessage", | ||||
|     "DeltaMessage", | ||||
|     "QueryRequest", | ||||
|     "ChatCompletionRequest", | ||||
|     "ChatCompletionResponseChoice", | ||||
|     "ChatCompletionResponseStreamChoice", | ||||
|     "ChatCompletionResponse", | ||||
|     "SearchItem", | ||||
|     "SearchItemList", | ||||
|     "SearchResp", | ||||
| ] | ||||
| @@ -109,5 +109,5 @@ | ||||
|     "isolatedModules": true | ||||
|   }, | ||||
|   "include": ["src", "src/declaration.d.ts", "src/next-auth.d.ts"], | ||||
|   "exclude": ["node_modules"] | ||||
|   "exclude": ["node_modules", "src-tauri"] | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user