diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..d1ea44c --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,20 @@ +""" +API модуль приложения +""" +from .schemas import ( + ParserOneRequest, + Parserall, + Parserall_url, + Source, + DownloadRange +) +from .routes import setup_routes + +__all__ = [ + 'ParserOneRequest', + 'Parserall', + 'Parserall_url', + 'Source', + 'DownloadRange', + 'setup_routes' +] diff --git a/api/routes.py b/api/routes.py new file mode 100644 index 0000000..5aa6bba --- /dev/null +++ b/api/routes.py @@ -0,0 +1,258 @@ +""" +API эндпоинты приложения +""" +import os +import zipfile +from datetime import datetime, timedelta +from typing import List + +from fastapi import BackgroundTasks, FastAPI, Query, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse + +from config import DOCUMENTS_DIR, APP_TITLE, APP_DESCRIPTION, APP_VERSION +from utils import logger +from api.schemas import ParserOneRequest, Parserall, Source, DownloadRange +from parsers import start_pars_one_istochnik, start_pars_two_istochnik, start_pars_all_istochnik +import work_parser as wp +import parser_bd as pbd + + +def setup_routes(app: FastAPI) -> None: + """ + Настройка всех API маршрутов + """ + + # CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ==================== Парсеры ==================== + + @app.post("/parser_1", summary="Запуск процесса парсинга первого источника") + async def process_parser_one_ist(data: ParserOneRequest, background_tasks: BackgroundTasks): + istochnik = data.time.split("-") + background_tasks.add_task(start_pars_one_istochnik, istochnik) + return {"message": "Процесс парсинга 1 источника запущен"} + + @app.post("/parser_2", summary="Запуск процесса парсинга второго источника") + async def process_parser_two_ist(background_tasks: BackgroundTasks): + background_tasks.add_task(start_pars_two_istochnik) + return {"message": "Процесс парсинга 2 источника запущен"} + + @app.post("/add_sources", summary="Добавление парсинга любого источника") + async def add_sources_all_ist(sources: Parserall): + result = wp.add_sources(str(sources.url), sources.promt) + return {"status": "success", "message": "Источник добавлен", "data": result} + + @app.get("/all_sources", summary="Метод получения всех источников") + async def get_all_sources(): + return wp.get_all_sources() + + @app.delete("/delete_sources", summary="Метод удаления источника") + async def delete_sources(url: str): + return print(wp.delete_sources(url)) + + @app.post("/parser_all", summary="Запуск процесса парсинга любого источника") + async def process_parser_all_ist(url: Parserall, background_tasks: BackgroundTasks): + background_tasks.add_task(start_pars_all_istochnik, str(url.url), url.promt) + return {"message": "Процесс парсинга любого источника запущен"} + + @app.get("/get_tasks_offset", summary="Метод получения задач парсинга") + async def get_tasks_offset(limit: int = Query(10, gt=0), offset: int = Query(0, ge=0)): + return wp.get_tasks_offset(limit, offset) + + # ==================== Настройки ==================== + + @app.get("/settings", summary="Метод получения настроек парсера") + async def get_settings(): + return wp.get_all_promt() + + @app.get("/categories_promt", summary="Метод получения categories_promt") + async def get_categories_promt(): + return wp.get_all_categories_promt() + + @app.post("/settings", summary="Метод сохранения настроек парсера") + async def set_settings(settings: Source): + return wp.update_promt(settings.name, settings.promt) + + # ==================== Задачи ==================== + + @app.delete("/delete_task/{task_id}", summary="Метод удаления задачи") + async def delete_task(task_id: int): + return print(wp.delete_task(task_id)) + + # ==================== Файлы ==================== + + @app.get("/file_download", summary="Метод для скачивания файла") + async def download_file(path: str, title: str): + file_name = f"{title}.docx" + file_path = os.path.join(DOCUMENTS_DIR, path, file_name) + logger.warning(f"Файл: {file_path}") + + if not os.path.exists(file_path): + logger.warning(f"Файл не найден: {file_path}") + return {"error": "Файл не найден", "path": file_path} + + response = FileResponse( + path=file_path, + filename=file_name, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + + logger.warning(response) + return response + + @app.post("/download_all", summary="Скачать все файлы за период") + async def download_all(dates: DownloadRange, background_tasks: BackgroundTasks): + date_start = dates.data_start + date_finish = dates.data_finish + + try: + start_date = datetime.strptime(date_start, "%Y-%m-%d") + finish_date = datetime.strptime(date_finish, "%Y-%m-%d") + except ValueError: + return {"error": "Неверный формат даты. Используйте YYYY-MM-DD"} + + if start_date > finish_date: + return {"error": "Дата начала не может быть позже даты окончания"} + + all_files = [] + + current_date = start_date + while current_date <= finish_date: + date_path = current_date.strftime("%Y/%m/%d") + full_dir_path = os.path.join(DOCUMENTS_DIR, date_path) + + if os.path.exists(full_dir_path): + for file in os.listdir(full_dir_path): + if file.endswith('.docx'): + all_files.append(os.path.join(full_dir_path, file)) + + current_date += timedelta(days=1) + + if not all_files: + return {"error": "Файлы не найдены за указанный период", "date_start": date_start, "date_finish": date_finish} + + archive_name = f"documents_{date_start}_{date_finish}.zip" + archive_path = os.path.join(DOCUMENTS_DIR, archive_name) + + try: + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for file_path in all_files: + zipf.write(file_path, os.path.basename(file_path)) + except Exception as e: + logger.error(f"Ошибка создания архива: {e}") + return {"error": f"Ошибка создания архива: {e}"} + + def cleanup_archive(): + try: + if os.path.exists(archive_path): + os.remove(archive_path) + logger.info(f"Архив удалён: {archive_path}") + except Exception as e: + logger.warning(f"Не удалось удалить архив: {e}") + + response = FileResponse( + path=archive_path, + filename=archive_name, + media_type="application/zip" + ) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + response.headers["Access-Control-Expose-Headers"] = "Content-Disposition" + + background_tasks.add_task(cleanup_archive) + + return response + + @app.get("/logs", summary="Показать логи") + async def get_logs(): + with open("app.log", "r") as file: + lines = file.readlines()[-10:] + return {"logs": lines} + + # ==================== Эндпоинты из parser_bd.py ==================== + + @app.post("/save_parsed_data", summary="Сохранить данные парсинга") + def save_parsed_data(data: pbd.ParsedData): + try: + pbd.save_parsed_data_to_db(data) + return {"status": "success", "message": "Данные успешно сохранены"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при сохранении данных: {e}") + + @app.post("/update_viewed_status", summary="Обновляет поле viewed") + def update_viewed_status(url: str, viewed: bool): + try: + result = pbd.update_viewed_status_in_db(url, viewed) + if not result.get("found"): + raise HTTPException(status_code=404, detail="Запись с указанным URL не найдена") + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Ошибка при сохранении данных: {e}") + return {"status": "success", "message": "Статус просмотра успешно обновлен"} + + @app.post("/update_status_status", summary="Обновляет поле status") + def update_status_status(url: str, status: bool): + try: + result = pbd.update_status_status_in_db(url, status) + if not result.get("found"): + raise HTTPException(status_code=404, detail="Запись с указанным URL не найдена") + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Ошибка при сохранении данных: {e}") + return {"status": "success", "message": "Статус просмотра успешно обновлен"} + + @app.get("/check_url_exists", summary="Проверяет url") + def check_url_exists(url: str): + try: + return pbd.check_url_exists_in_db(url) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при проверке: {e}") + + @app.get("/records", summary="Получить записи из БД с пагинацией", response_model=List[pbd.ParsedData]) + def get_records(offset: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100)): + try: + return pbd.get_records_from_db(offset, limit) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при получении записей из БД: {e}") + + @app.get("/records_all/count", summary="Получить общее количество записей") + def get_records_count(item: str = Query("default")): + try: + return pbd.get_records_count_from_db(item) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при получении количества: {e}") + + @app.get("/poisk/count", summary="Получить количество результатов поиска") + def get_poisk_count(query: str, item: str = Query("default")): + try: + return pbd.get_poisk_count_from_db(query, item) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при получении количества: {e}") + + @app.get("/poisk", summary="Поиск с пагинацией") + def poisk(query: str, offset: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100), item: str = Query("default")): + try: + return pbd.poisk_from_db(query, offset, limit, item) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при поиске: {e}") + + @app.get("/records_all", summary="Получить все записи из БД + сортирует + пагинация", response_model=List[pbd.ParsedData]) + def get_records_all(item: str = Query("default"), offset: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100)): + try: + return pbd.get_records_all_from_db(item, offset, limit) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при получении записей из БД: {e}") diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..af72705 --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,33 @@ +""" +Pydantic схемы для API +""" +from pydantic import BaseModel, HttpUrl +from typing import List + + +class ParserOneRequest(BaseModel): + """Запрос для парсинга первого источника""" + time: str + + +class Parserall(BaseModel): + """Запрос для парсинга любого источника""" + url: HttpUrl + promt: str + + +class Parserall_url(BaseModel): + """Запрос URL для источника""" + url: HttpUrl + + +class Source(BaseModel): + """Модель источника для настроек""" + name: str + promt: str + + +class DownloadRange(BaseModel): + """Диапазон дат для скачивания файлов""" + data_start: str + data_finish: str diff --git a/config.py b/config.py new file mode 100644 index 0000000..f009d59 --- /dev/null +++ b/config.py @@ -0,0 +1,36 @@ +""" +Конфигурация приложения +""" +import os + +# Пути +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DOCUMENTS_DIR = os.path.join(BASE_DIR, "documents") +LOG_FILE = os.path.join(BASE_DIR, "app.log") + +# GPT сервер +GPT_SERVER_URL = os.getenv('GPT_SERVER_URL', 'http://45.129.78.228:8484') +# GPT_SERVER_URL = os.getenv('GPT_SERVER_URL', 'http://127.0.0.1:8484') + +# Прокси +PROXIES_URL = "https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt" + +# FastAPI настройки +APP_TITLE = "Parser API" +APP_DESCRIPTION = "API для запуска парсинга в базу данных" +APP_VERSION = "1.0" +UVICORN_PORT = 8001 + +# Настройки парсера +PARSER_TIMEOUT = 10 +GPT_TIMEOUT = 60 +GPT_MAX_RETRIES = 5 +MAX_ARTICLE_TEXT_LENGTH = 4500 +MIN_ARTICLE_TEXT_LENGTH = 100 +MIN_UNIVERSAL_ARTICLE_TEXT_LENGTH = 200 + +# Планировщик +SCHEDULED_PARSER_1_HOUR = 0 +SCHEDULED_PARSER_1_MINUTE = 0 +SCHEDULED_PARSER_2_HOUR = 1 +SCHEDULED_PARSER_2_MINUTE = 0 diff --git a/main.py b/main.py index 5d520f9..64bd629 100644 --- a/main.py +++ b/main.py @@ -1,735 +1,54 @@ -# Стандартные библиотеки (stdlib) -import json -import logging -import os -import subprocess -import time -from datetime import datetime as dt -from datetime import datetime, timedelta -import random - -import zipfile -import tempfile - -# Сторонние библиотеки (third-party) -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from bs4 import BeautifulSoup +""" +Parser API - Точка входа приложения +""" from contextlib import asynccontextmanager -from docx import Document -from newspaper import Article -from fastapi import BackgroundTasks, FastAPI, Query, Request, Depends, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from pydantic import BaseModel, HttpUrl -from typing import List -from urllib.parse import urljoin, urlparse, urldefrag +from fastapi import FastAPI import uvicorn -import requests - -# Локальные импорты -import parser_bd as pbd -import work_parser as wp +from config import ( + APP_TITLE, + APP_DESCRIPTION, + APP_VERSION, + UVICORN_PORT, + SCHEDULED_PARSER_1_HOUR, + SCHEDULED_PARSER_1_MINUTE, + SCHEDULED_PARSER_2_HOUR, + SCHEDULED_PARSER_2_MINUTE +) +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from parsers import scheduled_parser_1, scheduled_parser_2 +from api import setup_routes +# Инициализация планировщика +scheduler = AsyncIOScheduler() -DOCUMENTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "documents") @asynccontextmanager async def lifespan(app: FastAPI): """Управление жизненным циклом приложения""" # Startup - scheduler.add_job(scheduled_parser_1, "cron", hour=0, minute=0) - scheduler.add_job(scheduled_parser_2, "cron", hour=1, minute=0) + scheduler.add_job(scheduled_parser_1, "cron", hour=SCHEDULED_PARSER_1_HOUR, minute=SCHEDULED_PARSER_1_MINUTE) + scheduler.add_job(scheduled_parser_2, "cron", hour=SCHEDULED_PARSER_2_HOUR, minute=SCHEDULED_PARSER_2_MINUTE) scheduler.start() yield # Shutdown scheduler.shutdown() -app = FastAPI(title="Parser API", - description="API для запуска парсинга в базу данных", - version="1.0", - lifespan=lifespan) -# Инициализация планировщика -scheduler = AsyncIOScheduler() - -# Настройка логгера -logging.basicConfig(filename="app.log", level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") -logger = logging.getLogger(__name__) - -# Инициализация таблицы статуса парсинга -# wp.create_table() - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # или список разрешенных адресов, например ["https://allowlgroup.ru","http://localhost:5173", "http://45.129.78.228:8000"] - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - # expose_headers=["*"], +# Создание приложения FastAPI +app = FastAPI( + title=APP_TITLE, + description=APP_DESCRIPTION, + version=APP_VERSION, + lifespan=lifespan ) -PROXIES_URL = "https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt" - -def download_proxies(url): - response = requests.get(url) - if response.status_code == 200: - proxies = response.text.splitlines() # список прокси по строкам - return proxies - else: - return [] - -def fetch_with_proxy(url, proxy, verify, timeout): - proxies = { - 'http': f'http://{proxy}', - 'https': f'http://{proxy}', - } - try: - response = requests.get(url, proxies=proxies, timeout=timeout, verify=verify) - response.encoding = 'utf-8' - if response.status_code == 200: - # Проверяем содержимое - если это ошибка от прокси - if '"message":"Request failed' in response.text or '403' in response.text[:500]: - print(f"Proxy {proxy} - Site returned 403 (inside response)") - return None - print(f"Proxy {proxy} - SUCCESS") - return response.text - elif response.status_code == 403: - print(f"Proxy {proxy} - 403 Forbidden") - return None # Прокси работает, но сайт блокирует - else: - print(f"Proxy {proxy} - Status {response.status_code}") - return None - except: - return None - -# Перемешивает список прокси для случайного начала -def get_shuffled_proxies(proxies_list): - shuffled = proxies_list.copy() - random.shuffle(shuffled) - return shuffled - -# Общие функции нахождения ссылок -def extract_map_area_hrefs(url, verify=True, ist_number=1): - headers = { - "User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0; +https://example.com)" - } - - resp = requests.get(url, headers=headers, verify=verify) - - resp.raise_for_status() - - soup = BeautifulSoup(resp.text, "html.parser") - - hrefs = [] - if ist_number == 1: - for map_tag in soup.find_all("li", attrs={"data-page": "1"}): - for a in map_tag.find_all("a", href=True): - href = a["href"] - abs_url = urljoin(url, href) - print(abs_url) - hrefs.append(abs_url) - else: - for map_tag in soup.find_all("map"): - for area in map_tag.find_all("area", href=True): - href = area["href"] - abs_url = urljoin(url, href) - hrefs.append(abs_url) - return hrefs - -# функции парсера первого источника (газета) -def extract_text_from_url_one(url, timeout=10, verify=True): - proxies_list = download_proxies(PROXIES_URL) - proxies_list = get_shuffled_proxies(proxies_list) - - response = "" - for proxy in proxies_list: - response = fetch_with_proxy(url, proxy=proxy, timeout=timeout, verify=verify) - if response: - break - else: - response = "" - - soup = BeautifulSoup(response, "html.parser") - - title_div = soup.find('div', class_='newsdetatit') - title_text = '' - if title_div: - h3_tag = title_div.find('h3') - if h3_tag: - title_text = h3_tag.get_text(strip=True) - - content_div = soup.find('div', class_='newsdetatext') - content_text = '' - if content_div: - founder_content = content_div.find('founder-content') - if founder_content: - p_tags = founder_content.find_all('p') - content_text = '\n'.join(p.get_text(strip=True) for p in p_tags) - - text = title_text + content_text - - if len(text) > 4500: - text = text[:4500] - print(len(text)) - return text - -#Функции парсера второго источника (военного) -def extract_text_from_url(url, timeout=10, verify=True): - proxies_list = download_proxies(PROXIES_URL) - proxies_list = get_shuffled_proxies(proxies_list) - response = "" - for proxy in proxies_list: - response = fetch_with_proxy(url, proxy=proxy, timeout=timeout, verify=verify) - if response: - break - else: - response = "" - - soup = BeautifulSoup(response, 'html.parser') - - # Находим контейнер div.whitecon.article - container = soup.find("div", class_="whitecon article") - if not container: - return "", "" - - # Получение заголовка