mirror of
https://github.com/jaypyles/Scraperr.git
synced 2026-08-26 14:26:31 +00:00
wip: mutli page scraping and worker
This commit is contained in:
+4
-2
@@ -32,7 +32,7 @@ RUN npm run build
|
||||
FROM python:3.10-slim
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y wget gnupg
|
||||
RUN apt-get install -y wget gnupg supervisor
|
||||
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add -
|
||||
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||
RUN apt-get update
|
||||
@@ -44,9 +44,11 @@ COPY --from=pybuilder /usr/local/bin /usr/local/bin
|
||||
COPY --from=pybuilder /project/app /project/
|
||||
COPY --from=jsbuilder /app/dist /project/dist
|
||||
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
WORKDIR /project/
|
||||
|
||||
CMD [ "pdm", "run", "python", "-m", "uvicorn", "api.backend.app:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD [ "supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf" ]
|
||||
|
||||
|
||||
+22
-25
@@ -1,18 +1,19 @@
|
||||
# STL
|
||||
from functools import partial
|
||||
import uuid
|
||||
import logging
|
||||
from io import StringIO
|
||||
|
||||
# PDM
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# LOCAL
|
||||
from api.backend.job import query, insert, delete_jobs
|
||||
from api.backend.job import query, insert, delete_jobs, update_job
|
||||
from api.backend.models import (
|
||||
DownloadJob,
|
||||
SubmitScrapeJob,
|
||||
@@ -21,6 +22,7 @@ from api.backend.models import (
|
||||
)
|
||||
from api.backend.scraping import scrape
|
||||
from api.backend.auth.auth_router import auth_router
|
||||
from seleniumwire.thirdparty.mitmproxy.master import traceback
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -55,23 +57,15 @@ def read_favicon():
|
||||
|
||||
|
||||
@app.post("/api/submit-scrape-job")
|
||||
async def submit_scrape_job(job: SubmitScrapeJob):
|
||||
async def submit_scrape_job(job: SubmitScrapeJob, background_tasks: BackgroundTasks):
|
||||
LOG.info(f"Recieved job: {job}")
|
||||
try:
|
||||
scraped = await scrape(job.url, job.elements)
|
||||
|
||||
LOG.info(
|
||||
f"Scraped result for url: {job.url}, with elements: {job.elements}\n{scraped}"
|
||||
)
|
||||
|
||||
json_scraped = jsonable_encoder(scraped)
|
||||
job.result = json_scraped
|
||||
job.id = uuid.uuid4().hex
|
||||
|
||||
if job.user:
|
||||
await insert(jsonable_encoder(job))
|
||||
|
||||
return JSONResponse(content=json_scraped)
|
||||
return JSONResponse(content=f"Job queued for scraping: {job.id}")
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||
|
||||
@@ -95,19 +89,21 @@ async def download(download_job: DownloadJob):
|
||||
|
||||
flattened_results = []
|
||||
for result in results:
|
||||
for key, values in result["result"].items():
|
||||
for value in values:
|
||||
flattened_results.append(
|
||||
{
|
||||
"id": result["id"],
|
||||
"url": result["url"],
|
||||
"element_name": key,
|
||||
"xpath": value["xpath"],
|
||||
"text": value["text"],
|
||||
"user": result["user"],
|
||||
"time_created": result["time_created"],
|
||||
}
|
||||
)
|
||||
for res in result["result"]:
|
||||
for url, elements in res.items():
|
||||
for element_name, values in elements.items():
|
||||
for value in values:
|
||||
flattened_results.append(
|
||||
{
|
||||
"id": result.get("id", None),
|
||||
"url": url,
|
||||
"element_name": element_name,
|
||||
"xpath": value.get("xpath", ""),
|
||||
"text": value.get("text", ""),
|
||||
"user": result.get("user", ""),
|
||||
"time_created": result.get("time_created", ""),
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(flattened_results)
|
||||
|
||||
@@ -120,6 +116,7 @@ async def download(download_job: DownloadJob):
|
||||
|
||||
except Exception as e:
|
||||
LOG.error(f"Exception occurred: {e}")
|
||||
traceback.print_exc()
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# STL
|
||||
import logging
|
||||
from typing import Any
|
||||
from pymongo import DESCENDING
|
||||
|
||||
# LOCAL
|
||||
from api.backend.database import get_job_collection
|
||||
@@ -14,6 +15,13 @@ async def insert(item: dict[str, Any]) -> None:
|
||||
LOG.info(f"Inserted item: {i}")
|
||||
|
||||
|
||||
async def get_queued_job():
|
||||
collection = get_job_collection()
|
||||
return await collection.find_one(
|
||||
{"status": "Queued"}, sort=[("created_at", DESCENDING)]
|
||||
)
|
||||
|
||||
|
||||
async def query(filter: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
collection = get_job_collection()
|
||||
cursor = collection.find(filter)
|
||||
@@ -26,6 +34,15 @@ async def query(filter: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
async def update_job(id: str, field: str, value: Any):
|
||||
collection = get_job_collection()
|
||||
result = await collection.update_one(
|
||||
{"id": id},
|
||||
{"$set": {field: value}},
|
||||
)
|
||||
return result.modified_count
|
||||
|
||||
|
||||
async def delete_jobs(jobs: list[str]):
|
||||
collection = get_job_collection()
|
||||
result = await collection.delete_many({"id": {"$in": jobs}})
|
||||
|
||||
@@ -17,6 +17,11 @@ class CapturedElement(pydantic.BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class JobOptions(pydantic.BaseModel):
|
||||
multi_page_scrape: bool
|
||||
custom_headers: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
class SubmitScrapeJob(pydantic.BaseModel):
|
||||
id: Optional[str] = None
|
||||
url: str
|
||||
@@ -24,6 +29,8 @@ class SubmitScrapeJob(pydantic.BaseModel):
|
||||
user: Optional[str] = None
|
||||
time_created: Optional[str] = None
|
||||
result: Optional[dict[str, Any]] = None
|
||||
job_options: JobOptions
|
||||
status: str = "Queued"
|
||||
|
||||
|
||||
class RetrieveScrapeJobs(pydantic.BaseModel):
|
||||
|
||||
+99
-13
@@ -1,10 +1,11 @@
|
||||
# STL
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
# PDM
|
||||
from bs4 import BeautifulSoup
|
||||
from lxml import etree
|
||||
from selenium import webdriver
|
||||
from seleniumwire import webdriver
|
||||
from lxml.etree import _Element # type: ignore [reportPrivateImport]
|
||||
from fake_useragent import UserAgent
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
@@ -13,6 +14,7 @@ from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
# LOCAL
|
||||
from api.backend.models import Element, CapturedElement
|
||||
@@ -23,6 +25,14 @@ LOG = logging.getLogger(__name__)
|
||||
class HtmlElement(_Element): ...
|
||||
|
||||
|
||||
def is_same_domain(url: str, original_url: str) -> bool:
|
||||
parsed_url = urlparse(url)
|
||||
parsed_original_url = urlparse(original_url)
|
||||
LOG.info(f"PARSED: {parsed_url.netloc}")
|
||||
LOG.info(f"PARSED_ORIGINAL: {parsed_original_url.netloc}")
|
||||
return parsed_url.netloc == parsed_original_url.netloc or parsed_url.netloc == ""
|
||||
|
||||
|
||||
def clean_xpath(xpath: str) -> str:
|
||||
parts = xpath.split("/")
|
||||
clean_parts: list[str] = []
|
||||
@@ -41,10 +51,25 @@ def sxpath(context: _Element, xpath: str) -> list[HtmlElement]:
|
||||
return context.xpath(xpath) # type: ignore [reportReturnType]
|
||||
|
||||
|
||||
async def make_site_request(url: str) -> str:
|
||||
"""Make basic `GET` request to site using Selenium."""
|
||||
ua = UserAgent()
|
||||
def interceptor(headers: dict[str, Any]):
|
||||
def _interceptor(request: Any):
|
||||
for key, val in headers.items():
|
||||
if request.headers.get(key):
|
||||
del request.headers[key]
|
||||
|
||||
request.headers[key] = val
|
||||
|
||||
if "sec-ch-ua" in request.headers:
|
||||
original_value = request.headers["sec-ch-ua"]
|
||||
del request.headers["sec-ch-ua"]
|
||||
modified_value = original_value.replace("HeadlessChrome", "Chrome")
|
||||
request.headers["sec-ch-ua"] = modified_value
|
||||
|
||||
return _interceptor
|
||||
|
||||
|
||||
def create_driver():
|
||||
ua = UserAgent()
|
||||
chrome_options = ChromeOptions()
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
@@ -52,23 +77,65 @@ async def make_site_request(url: str) -> str:
|
||||
chrome_options.add_argument(f"user-agent={ua.random}")
|
||||
|
||||
service = Service(ChromeDriverManager().install())
|
||||
driver = webdriver.Chrome(options=chrome_options, service=service)
|
||||
return webdriver.Chrome(options=chrome_options, service=service)
|
||||
|
||||
|
||||
async def make_site_request(
|
||||
url: str,
|
||||
headers: Optional[dict[str, Any]],
|
||||
multi_page_scrape: bool = False,
|
||||
visited_urls: set[str] = set(),
|
||||
pages: set[tuple[str, str]] = set(),
|
||||
original_url: str = "",
|
||||
) -> None:
|
||||
"""Make basic `GET` request to site using Selenium."""
|
||||
# Check if URL has already been visited
|
||||
if url in visited_urls:
|
||||
return
|
||||
|
||||
driver = create_driver()
|
||||
|
||||
if headers:
|
||||
driver.request_interceptor = interceptor(headers)
|
||||
|
||||
try:
|
||||
driver.get(url)
|
||||
visited_urls.add(url)
|
||||
_ = WebDriverWait(driver, 10).until(
|
||||
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
||||
)
|
||||
page_source = driver.page_source
|
||||
LOG.debug(f"Page source for url: {url}\n{page_source}")
|
||||
pages.add((page_source, url))
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
LOG.debug(f"Page source for url: {url}\n{page_source}")
|
||||
return page_source
|
||||
if not multi_page_scrape:
|
||||
return
|
||||
|
||||
soup = BeautifulSoup(page_source, "html.parser")
|
||||
|
||||
for a_tag in soup.find_all("a"):
|
||||
link = a_tag.get("href")
|
||||
|
||||
if link:
|
||||
if not urlparse(link).netloc:
|
||||
base_url = "{0.scheme}://{0.netloc}".format(urlparse(original_url))
|
||||
link = urljoin(base_url, link)
|
||||
|
||||
if link not in visited_urls and is_same_domain(link, original_url):
|
||||
await make_site_request(
|
||||
link,
|
||||
headers=headers,
|
||||
multi_page_scrape=multi_page_scrape,
|
||||
visited_urls=visited_urls,
|
||||
pages=pages,
|
||||
original_url=original_url,
|
||||
)
|
||||
|
||||
|
||||
async def collect_scraped_elements(page: str, xpaths: list[Element]):
|
||||
soup = BeautifulSoup(page, "lxml")
|
||||
async def collect_scraped_elements(page: tuple[str, str], xpaths: list[Element]):
|
||||
soup = BeautifulSoup(page[0], "lxml")
|
||||
root = etree.HTML(str(soup))
|
||||
|
||||
elements: dict[str, list[CapturedElement]] = dict()
|
||||
@@ -86,11 +153,30 @@ async def collect_scraped_elements(page: str, xpaths: list[Element]):
|
||||
|
||||
elements[elem.name] = [captured_element]
|
||||
|
||||
return elements
|
||||
return {page[1]: elements}
|
||||
|
||||
|
||||
async def scrape(url: str, xpaths: list[Element]):
|
||||
page = await make_site_request(url)
|
||||
elements = await collect_scraped_elements(page, xpaths)
|
||||
async def scrape(
|
||||
url: str,
|
||||
xpaths: list[Element],
|
||||
headers: Optional[dict[str, Any]],
|
||||
multi_page_scrape: bool = False,
|
||||
):
|
||||
visited_urls: set[str] = set()
|
||||
pages: set[tuple[str, str]] = set()
|
||||
|
||||
_ = await make_site_request(
|
||||
url,
|
||||
headers,
|
||||
multi_page_scrape=multi_page_scrape,
|
||||
visited_urls=visited_urls,
|
||||
pages=pages,
|
||||
original_url=url,
|
||||
)
|
||||
|
||||
elements: list[dict[str, dict[str, list[CapturedElement]]]] = list()
|
||||
|
||||
for page in pages:
|
||||
elements.append(await collect_scraped_elements(page, xpaths))
|
||||
|
||||
return elements
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from api.backend.job import get_queued_job, update_job
|
||||
from api.backend.scraping import scrape
|
||||
from api.backend.models import Element
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_job():
|
||||
job = await get_queued_job()
|
||||
if job:
|
||||
LOG.info(f"Beginning processing job: {job}.")
|
||||
try:
|
||||
_ = await update_job(job["id"], field="status", value="Scraping")
|
||||
scraped = await scrape(
|
||||
job["url"],
|
||||
[Element(**j) for j in job["elements"]],
|
||||
job["job_options"]["custom_headers"],
|
||||
job["job_options"]["multi_page_scrape"],
|
||||
)
|
||||
LOG.info(
|
||||
f"Scraped result for url: {job['url']}, with elements: {job['elements']}\n{scraped}"
|
||||
)
|
||||
_ = await update_job(
|
||||
job["id"], field="result", value=jsonable_encoder(scraped)
|
||||
)
|
||||
_ = await update_job(job["id"], field="status", value="Completed")
|
||||
except Exception as e:
|
||||
_ = await update_job(job["id"], field="status", value="Failed")
|
||||
_ = await update_job(job["id"], field="result", value=e)
|
||||
LOG.error(f"Exception as occured: {e}\n{traceback.print_exc()}")
|
||||
|
||||
|
||||
async def main():
|
||||
LOG.info("Starting job worker...")
|
||||
while True:
|
||||
await process_job()
|
||||
await asyncio.sleep(5) # Sleep for 5 seconds before checking for new jobs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
scraperr:
|
||||
image: jpyles0524/scraperr:latest
|
||||
init: True
|
||||
build:
|
||||
context: ./
|
||||
container_name: scraperr
|
||||
|
||||
@@ -30,6 +30,9 @@ dependencies = [
|
||||
"motor[asyncio]>=3.5.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"selenium-wire>=5.1.0",
|
||||
"blinker<1.8.0",
|
||||
"setuptools>=71.0.4",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -33,6 +33,8 @@ interface Job {
|
||||
elements: Object[];
|
||||
result: Object;
|
||||
time_created: Date;
|
||||
status: string;
|
||||
job_options: Object;
|
||||
}
|
||||
|
||||
interface JobTableProps {
|
||||
@@ -40,6 +42,16 @@ interface JobTableProps {
|
||||
fetchJobs: () => void;
|
||||
}
|
||||
|
||||
interface ColorMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const COLOR_MAP: ColorMap = {
|
||||
Queued: "rgba(255,201,5,0.5)",
|
||||
Scraping: "rgba(3,104,255,0.5)",
|
||||
Completed: "rgba(5,255,51,0.5)",
|
||||
};
|
||||
|
||||
const JobTable: React.FC<JobTableProps> = ({ jobs, fetchJobs }) => {
|
||||
const [selectedJobs, setSelectedJobs] = useState<Set<string>>(new Set());
|
||||
const [allSelected, setAllSelected] = useState(false);
|
||||
@@ -70,12 +82,14 @@ const JobTable: React.FC<JobTableProps> = ({ jobs, fetchJobs }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (elements: Object[], url: string) => {
|
||||
const handleNavigate = (elements: Object[], url: string, options: any) => {
|
||||
console.log(options);
|
||||
router.push({
|
||||
pathname: "/",
|
||||
query: {
|
||||
elements: JSON.stringify(elements),
|
||||
url: url,
|
||||
job_options: JSON.stringify(options),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -212,6 +226,7 @@ const JobTable: React.FC<JobTableProps> = ({ jobs, fetchJobs }) => {
|
||||
<TableCell>Elements</TableCell>
|
||||
<TableCell>Result</TableCell>
|
||||
<TableCell>Time Created</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -283,6 +298,16 @@ const JobTable: React.FC<JobTableProps> = ({ jobs, fetchJobs }) => {
|
||||
{new Date(row.time_created).toLocaleString()}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 150, overflow: "auto" }}>
|
||||
<Box sx={{ maxHeight: 100, overflow: "auto" }}>
|
||||
<Box
|
||||
className="rounded-md p-2 text-center"
|
||||
sx={{ bgcolor: COLOR_MAP[row.status], opactity: "50%" }}
|
||||
>
|
||||
{row.status}
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 100, overflow: "auto" }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -292,7 +317,9 @@ const JobTable: React.FC<JobTableProps> = ({ jobs, fetchJobs }) => {
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleNavigate(row.elements, row.url)}
|
||||
onClick={() =>
|
||||
handleNavigate(row.elements, row.url, row.job_options)
|
||||
}
|
||||
>
|
||||
Rerun
|
||||
</Button>
|
||||
|
||||
@@ -125,9 +125,12 @@ const NavDrawer: React.FC<NavDrawerProps> = ({ toggleTheme, isDarkMode }) => {
|
||||
<Typography>Settings</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Tooltip title="Dark Theme Toggle" placement="bottom">
|
||||
<div className="flex flex-row mr-1">
|
||||
<Typography className="mr-2">
|
||||
<p className="text-sm">Dark Theme Toggle</p>
|
||||
</Typography>
|
||||
<Switch checked={isDarkMode} onChange={toggleTheme} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import React, { useState, useEffect, useRef, Dispatch } from "react";
|
||||
import React, { useEffect, useState, Dispatch } from "react";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { Element, Result } from "../../types";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
interface stateProps {
|
||||
interface StateProps {
|
||||
submittedURL: string;
|
||||
setSubmittedURL: Dispatch<React.SetStateAction<string>>;
|
||||
rows: Element[];
|
||||
setResults: Dispatch<React.SetStateAction<Result>>;
|
||||
setSnackbarMessage: Dispatch<React.SetStateAction<string>>;
|
||||
setSnackbarOpen: Dispatch<React.SetStateAction<boolean>>;
|
||||
setSnackbarSeverity: Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stateProps: stateProps;
|
||||
stateProps: StateProps;
|
||||
}
|
||||
|
||||
interface JobOptions {
|
||||
multi_page_scrape: boolean;
|
||||
custom_headers: null | Object;
|
||||
custom_headers: null | string;
|
||||
}
|
||||
|
||||
export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const { job_options } = router.query;
|
||||
|
||||
const {
|
||||
submittedURL,
|
||||
@@ -42,6 +43,7 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
setResults,
|
||||
setSnackbarMessage,
|
||||
setSnackbarOpen,
|
||||
setSnackbarSeverity,
|
||||
} = stateProps;
|
||||
|
||||
const [isValidURL, setIsValidUrl] = useState<boolean>(true);
|
||||
@@ -51,6 +53,7 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
multi_page_scrape: false,
|
||||
custom_headers: null,
|
||||
});
|
||||
const [customJSONSelected, setCustomJSONSelected] = useState<boolean>(false);
|
||||
|
||||
function validateURL(url: string): boolean {
|
||||
try {
|
||||
@@ -72,6 +75,19 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
setUrlError(null);
|
||||
setLoading(true);
|
||||
|
||||
let customHeaders;
|
||||
try {
|
||||
customHeaders = jobOptions.custom_headers
|
||||
? JSON.parse(jobOptions.custom_headers)
|
||||
: null;
|
||||
} catch (error) {
|
||||
setSnackbarMessage("Invalid JSON in custom headers.");
|
||||
setSnackbarOpen(true);
|
||||
setSnackbarSeverity("error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/submit-scrape-job", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -80,6 +96,10 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
elements: rows,
|
||||
user: user?.email,
|
||||
time_created: new Date().toISOString(),
|
||||
job_options: {
|
||||
...jobOptions,
|
||||
custom_headers: customHeaders,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -90,14 +110,43 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => setResults(data))
|
||||
.then((data) => {
|
||||
console.log(data);
|
||||
setSnackbarMessage(data);
|
||||
setSnackbarSeverity("info");
|
||||
setSnackbarOpen(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
setSnackbarMessage(error.message || "An error occurred.");
|
||||
setSnackbarSeverity("error");
|
||||
setSnackbarOpen(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (job_options) {
|
||||
const jsonOptions = JSON.parse(job_options as string);
|
||||
const newJobOptions: JobOptions = {
|
||||
multi_page_scrape: false,
|
||||
custom_headers: null,
|
||||
};
|
||||
|
||||
if (
|
||||
jsonOptions.custom_headers &&
|
||||
Object.keys(jsonOptions.custom_headers).length
|
||||
) {
|
||||
setCustomJSONSelected(true);
|
||||
newJobOptions.custom_headers = JSON.stringify(
|
||||
jsonOptions.custom_headers
|
||||
);
|
||||
}
|
||||
|
||||
newJobOptions.multi_page_scrape = jsonOptions.multi_page_scrape;
|
||||
setJobOptions(newJobOptions);
|
||||
}
|
||||
}, [job_options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row space-x-4 items-center mb-2">
|
||||
@@ -120,40 +169,47 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
{loading ? <CircularProgress size={24} /> : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
<Box
|
||||
bgcolor="background.paper"
|
||||
className="p-2 mb-2 flex flex-row space-x-2"
|
||||
>
|
||||
<FormControlLabel
|
||||
label="Multi-Page Scrape"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={jobOptions.multi_page_scrape}
|
||||
onChange={() =>
|
||||
setJobOptions((prevJobOptions) => ({
|
||||
...prevJobOptions,
|
||||
multi_page_scrape: !prevJobOptions.multi_page_scrape,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
></FormControlLabel>
|
||||
<Accordion style={{ padding: 1 }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls="panel1-content"
|
||||
id="panel1-header"
|
||||
>
|
||||
Custom Headers (JSON)
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box bgcolor="background.paper" className="flex flex-col mb-2">
|
||||
<div id="options" className="p-2 flex flex-row space-x-2">
|
||||
<FormControlLabel
|
||||
label="Multi-Page Scrape"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={jobOptions.multi_page_scrape}
|
||||
onChange={() =>
|
||||
setJobOptions((prevJobOptions) => ({
|
||||
...prevJobOptions,
|
||||
multi_page_scrape: !prevJobOptions.multi_page_scrape,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
}
|
||||
></FormControlLabel>
|
||||
<FormControlLabel
|
||||
label="Custom Headers (JSON)"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={customJSONSelected}
|
||||
onChange={() => {
|
||||
setCustomJSONSelected(!customJSONSelected);
|
||||
setJobOptions((prevJobOptions) => ({
|
||||
...prevJobOptions,
|
||||
custom_headers: "",
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
></FormControlLabel>
|
||||
</div>
|
||||
{customJSONSelected ? (
|
||||
<div id="custom-json" className="pl-2 pr-2 pb-2">
|
||||
<TextField
|
||||
InputLabelProps={{ shrink: false }}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
variant="outlined"
|
||||
value={jobOptions.custom_headers}
|
||||
value={jobOptions.custom_headers || ""}
|
||||
onChange={(e) =>
|
||||
setJobOptions((prevJobOptions) => ({
|
||||
...prevJobOptions,
|
||||
@@ -163,8 +219,8 @@ export const JobSubmitter = ({ stateProps }: Props) => {
|
||||
style={{ maxHeight: "20vh", overflow: "auto" }}
|
||||
className="mt-2"
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</div>
|
||||
) : null}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
+24
-8
@@ -6,14 +6,14 @@ import { ElementTable, JobSubmitter, ResultsTable } from "../components/submit";
|
||||
|
||||
const Home = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { elements, url } = router.query;
|
||||
|
||||
const [submittedURL, setSubmittedURL] = useState("");
|
||||
const [submittedURL, setSubmittedURL] = useState<string>("");
|
||||
const [rows, setRows] = useState<Element[]>([]);
|
||||
const [results, setResults] = useState<Result>({});
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState<string>("");
|
||||
const [snackbarSeverity, setSnackbarSeverity] = useState<string>("alert");
|
||||
|
||||
const resultsRef = useRef<HTMLTableElement | null>(null);
|
||||
|
||||
@@ -36,6 +36,22 @@ const Home = () => {
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
const ErrorSnackbar = () => {
|
||||
return (
|
||||
<Alert onClose={handleCloseSnackbar} severity="error">
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
const NotifySnackbar = () => {
|
||||
return (
|
||||
<Alert onClose={handleCloseSnackbar} severity="info">
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bgcolor="background.default"
|
||||
@@ -58,6 +74,7 @@ const Home = () => {
|
||||
setResults,
|
||||
setSnackbarMessage,
|
||||
setSnackbarOpen,
|
||||
setSnackbarSeverity,
|
||||
}}
|
||||
/>
|
||||
<ElementTable
|
||||
@@ -65,16 +82,15 @@ const Home = () => {
|
||||
setRows={setRows}
|
||||
submittedURL={submittedURL}
|
||||
/>
|
||||
<ResultsTable stateProps={{ results }} resultsRef={resultsRef} />
|
||||
{/* <ResultsTable stateProps={{ results }} resultsRef={resultsRef} /> */}
|
||||
</Container>
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseSnackbar}
|
||||
>
|
||||
<Alert onClose={handleCloseSnackbar} severity="error">
|
||||
{snackbarMessage}
|
||||
</Alert>
|
||||
<p>Woo</p>
|
||||
{/* {snackbarSeverity === "info" ? <NotifySnackbar /> : <ErrorSnackbar />} */}
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
|
||||
[program:api]
|
||||
command=pdm run python -m uvicorn api.backend.app:app --reload --host 0.0.0.0 --port 8000
|
||||
directory=/project
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:worker]
|
||||
command=pdm run python -m api.backend.worker.job_worker
|
||||
directory=/project
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile_maxbytes=0
|
||||
Reference in New Issue
Block a user