Compare commits

...

10 Commits

5 changed files with 308 additions and 122 deletions

30
.drone.yml Normal file
View File

@@ -0,0 +1,30 @@
kind: pipeline
type: docker
name: default
steps:
- name: build
image: python:3.11-slim
commands:
- pip install --no-cache-dir -r requirements.txt
- name: deploy
image: appleboy/drone-ssh
settings:
host: allowlgroup.ru
username:
from_secret: ssh_username
password:
from_secret: ssh_password
port: 22
script:
- docker stop parser || true
- docker rm parser || true
- docker pull gitea.allowlgroup.ru/allowlgroup/parser:latest
- docker run -d --name parser -p 8001:8001 -v /opt/parser_data:/app/documents gitea.allowlgroup.ru/allowlgroup/parser:latest
when:
branch:
- main
event:
- push
- custom

7
.gitignore vendored
View File

@@ -6,9 +6,10 @@ __pycache__/
*.log
# Виртуальное окружение
# venv/
# env/
.venv/
save/
2026/
2027/
# IDE
.vscode/
.idea/

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8001
# Запуск с uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

283
main.py
View File

@@ -1,50 +1,70 @@
from fastapi import FastAPI, Request, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import logging
import subprocess
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
# Стандартные библиотеки (stdlib)
import json
import logging
import os
import subprocess
import time
from datetime import datetime as dt
from datetime import datetime
import random
# Сторонние библиотеки (third-party)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from bs4 import BeautifulSoup
from contextlib import asynccontextmanager
from docx import Document
from fastapi import BackgroundTasks, FastAPI, Query, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from urllib.parse import urljoin
import uvicorn
import time
from datetime import datetime
import requests
# Локальные импорты
import settings_work as sw
import work_parser as wp
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=10, minute=0)
scheduler.add_job(scheduled_parser_2, "cron", hour=11, minute=0)
scheduler.start()
yield
# Shutdown
scheduler.shutdown()
app = FastAPI(title="Parser API",
description="API для запуска парсинга в базу данных",
version="1.0")
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__)
@app.get("/logs")
def get_logs():
with open("app.log", "r") as file:
lines = file.readlines()[-100:] # последние 100 строк
return {"logs": lines}
# Инициализация таблицы статуса парсинга
wp.create_table()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # или список разрешенных адресов, например ["http://localhost:8080"]
allow_origins=["*"], # или список разрешенных адресов, например ["https://allowlgroup.ru","http://localhost:5173", "http://45.129.78.228:8000"]
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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:
@@ -53,20 +73,34 @@ def download_proxies(url):
else:
return []
def fetch_with_proxy(url, proxy, verify, timeout):
proxies = {
'http': f'http://{proxy}', # или 'socks5://' если SOCKS5 и т.п.
'http': f'http://{proxy}',
'https': f'http://{proxy}',
}
try:
response = requests.get(url, proxies=proxies, timeout=timeout, verify=verify)
response.encoding = 'utf-8'
response.raise_for_status()
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):
@@ -98,8 +132,8 @@ def extract_map_area_hrefs(url, verify=True, ist_number=1):
# функции парсера первого источника (газета)
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:
@@ -111,7 +145,6 @@ def extract_text_from_url_one(url, timeout=10, verify=True):
soup = BeautifulSoup(response, "html.parser")
title_div = soup.find('div', class_='newsdetatit')
title_text = ''
if title_div:
@@ -137,7 +170,7 @@ def extract_text_from_url_one(url, timeout=10, verify=True):
#Функции парсера второго источника (военного)
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)
@@ -151,7 +184,7 @@ def extract_text_from_url(url, timeout=10, verify=True):
# Находим контейнер div.whitecon.article
container = soup.find("div", class_="whitecon article")
if not container:
return ""
return "", ""
# Получение заголовка <time> внутри контейнера
time_text = container.find('span')
@@ -169,16 +202,23 @@ def extract_text_from_url(url, timeout=10, verify=True):
return "\n".join(content_text), time_t
# Общий запрос на GPT
def gpt_response_message(content, ist_number=1):
Promts = sw.read_settings().sources
# Promts = sw.read_settings().sources
# if ist_number == 1:
# contentGPT = Promts[0].prompt.replace('{content}', content)
# else:
# contentGPT = Promts[1].prompt.replace('{content}', content)
if ist_number == 1:
contentGPT = Promts[0].prompt.replace('{content}', content)
url_ist = "http://epaper.hljnews.cn/hljrb/pc/layout"
else:
contentGPT = Promts[1].prompt.replace('{content}', content)
url_ist = "https://def.ltn.com.tw/breakingnewslist"
contentGPT = wp.get_promt(url_ist).replace('{content}', content)
url = 'http://45.129.78.228:8484' #10.8.0.14:5500
params = {'text': contentGPT}
@@ -196,25 +236,18 @@ def gpt_response_message(content, ist_number=1):
logger.info(f"gpt_response_message: {ex}")
retries += 1
else:
restart_service('work_gpt.service')
print(f"\n\n\tПерезапуск GPT\n\n")
try:
response = requests.get(url, params=params, timeout=15)
return response.text
except Exception as ex:
print(f"Ошибка при запросе к GPT: {ex}")
logger.info(f"gpt_response_message: {ex}")
retries += 1
logger.info(f"Привышен лимит запросов {max_retries}")
return ""
def restart_service(service_name):
try:
subprocess.run(['sudo', 'systemctl', 'restart', service_name], check=True)
time.sleep(30)
print(f"Сервис {service_name} успешно перезапущен")
except subprocess.CalledProcessError:
print(f"Не удалось перезапустить сервес {service_name}")
# перезапуск сервиса GPT при неудачных попытках запроса
# def restart_service(service_name):
# try:
# subprocess.run(['sudo', 'systemctl', 'restart', service_name], check=True)
# time.sleep(30)
# print(f"Сервис {service_name} успешно перезапущен")
# except subprocess.CalledProcessError:
# print(f"Не удалось перезапустить сервес {service_name}")
# Общие функции проверки ссылок
@@ -238,6 +271,56 @@ def create_folder(num):
num = str(num)
return num
# Функция формирования документа
def update_bd_and_create_document(response_text, article_date, url, parsed_at, original_text, other):
clean_response = ''
if not response_text:
print(f"Пустой ответ от GPT для URL: {url}")
logger.info(f"Пустой ответ от GPT для URL: {url}")
return
try:
clean_response = response_text.strip().replace('```json', '').replace('```', '').strip()
data = json.loads(clean_response)
if data['category']:
data['article_date'] = article_date
data['url'] = url
data['parsed_at'] = parsed_at
data['original_text'] = original_text
data['status'] = False
data['viewed'] = False
data['other'] = other
print(requests.post('http://45.129.78.228:8002/save_parsed_data', json=data))
path_day = article_date.split()[0]
documents_path = os.path.join(DOCUMENTS_DIR, path_day)
if not os.path.exists(documents_path):
os.makedirs(documents_path)
print(f"Создана папка: {documents_path}")
doc = Document()
doc.add_heading('Ссылка на статью', level=1)
doc.add_paragraph(other)
doc.add_heading('Дата и время', level=1)
doc.add_paragraph(article_date)
doc.add_heading('Обноруженные тематики текста', level=1)
doc.add_paragraph(data["category"])
doc.add_heading('Заголовок', level=1)
doc.add_paragraph(data["title"])
doc.add_heading('Краткий пересказ', level=1)
doc.add_paragraph(data["short_text"])
doc.add_heading('Переведенный текст статьи в газете', level=1)
doc.add_paragraph(data["translation_text"])
doc.add_heading('Оригинальный текст', level=1)
doc.add_paragraph(original_text)
doc_name = f"{data['title']}.docx"
doc_path = os.path.join(documents_path, doc_name)
doc.save(doc_path)
print(f"Сохранен документ: {doc_path}")
except Exception as ex:
print(f"Ошибка при обработке ответа GPT: {ex}")
logger.info(f"Ошибка при обработке ответа GPT: {ex}")
#Функции start первого источника (газета)
def start_pars_one_istochnik(data_init):
@@ -257,13 +340,13 @@ def start_pars_one_istochnik(data_init):
for page_number in range(1, 9):
start_url = f'http://epaper.hljnews.cn/hljrb/pc/layout/{current_year}{current_month}/{current_day}/node_0{page_number}.html'
url = f'http://epaper.hljnews.cn/hljrb/pc/layout/{current_year}{current_month}/{current_day}/node_0{page_number}.html'
wp.update_task(task_id, status='in_progress', source_url=start_url, started_at=datetime.utcnow())
wp.update_task(task_id, status='in_progress', source_url=url, started_at=datetime.utcnow())
print(f"Сбор href из: {start_url}")
print(f"Сбор href из: {url}")
try:
hrefs = extract_map_area_hrefs(start_url, ist_number=2)
hrefs = extract_map_area_hrefs(url, ist_number=2)
except Exception as e:
print(f"Ошибка при извлечении ссылок: {e}")
logger.info(f"extract_map_area_hrefs: {e}")
@@ -276,24 +359,8 @@ def start_pars_one_istochnik(data_init):
if len(text) >= 100:
response_text = gpt_response_message(text, ist_number=2)
print(response_text)
clean_response = ''
try:
clean_response = response_text.strip().replace('```json', '').replace('```', '').strip()
data = json.loads(clean_response)
data['article_date'] = f"{current_day}/{current_month}/{current_year}"
data['url'] = link
data['parsed_at'] = str(dt.now())
data['original_text'] = text
data['status'] = False
data['viewed'] = False
data['other'] = start_url
if data['category']:
print(requests.post('http://45.129.78.228:8002/save_parsed_data', json=data))
except Exception as ex:
print(f"Ошибка при обработке ответа GPT: {ex}")
logger.info(f"gpt_response_message: {ex}")
continue
if response_text:
update_bd_and_create_document(response_text=response_text, article_date=f"{current_year}/{current_month}/{current_day}", url=link, parsed_at=str(dt.now()), original_text=text, other=url)
wp.update_task(task_id, status='completed', finished_at=datetime.utcnow())
@@ -320,34 +387,20 @@ def start_pars_two_istochnik():
if len(text) >= 100:
response_text = gpt_response_message(text)
print(response_text)
clean_response = ''
try:
clean_response = response_text.strip().replace('json', '').replace('', '').strip()
data = json.loads(clean_response)
data['article_date'] = time_text
data['url'] = hrefs
data['parsed_at'] = str(dt.now())
data['original_text'] = text
data['status'] = False
data['viewed'] = False
data['other'] = url
# print[date]
if data['category']:
print(requests.post('http://45.129.78.228:8002/save_parsed_data', json=data))
except Exception as ex:
print(f"Ошибка при обработке ответа GPT: {ex}")
logger.info(f"Ошибка при обработке ответа GPT: {ex}")
continue
if response_text:
update_bd_and_create_document(response_text=response_text, article_date=time_text, url=hrefs, parsed_at=str(dt.now()), original_text=text, other=url)
except:
continue
wp.update_task(task_id, status='completed', finished_at=datetime.utcnow())
# Функции для автоматического запуска
def scheduled_parser_1():
istochnik = "" # пустая строка = текущая дата
start_pars_one_istochnik(istochnik.split("."))
def scheduled_parser_2():
start_pars_two_istochnik()
class ParserOneRequest(BaseModel):
time: str
@@ -371,13 +424,51 @@ def get_tasks_offset(limit: int = Query(10, gt=0), offset: int = Query(0, ge=0))
# GET метод для получения настроек
@app.get("/settings", summary="Метод получения настроек парсера")
def get_settings():
return sw.read_settings()
return wp.get_all_promt()
# POST метод для установки настроек
@app.post("/settings", summary="Метод сохранения настроек парсера")
def set_settings(settings: sw.Source):
return sw.update_source(settings)
def set_settings(settings: wp.Source):
return wp.update_promt(settings)
@app.delete("/delete_task/{task_id}", summary="Метод удаления задачи")
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):
# path = f"./{path}/{title}.docx" #os.path.abspath(path)
# return FileResponse(path=path, filename=f'{title}.docx', media_type='multipart/form-data')
@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.get("/logs")
def get_logs():
with open("app.log", "r") as file:
lines = file.readlines()[-10:] # последние 10 строк
return {"logs": lines}
# Запуск сервера для теста
# if __name__ == "__main__":
# uvicorn.run("main:app", port=8001, reload=True)

View File

@@ -1,12 +1,14 @@
import psycopg2
from psycopg2.extras import RealDictCursor
from pydantic import BaseModel, HttpUrl
# Подключение к БД (укажи свои параметры)
conn = psycopg2.connect(
dbname="parsed_url",
user="postgres",
password="qwertyqwerty123123",
host="127.0.0.1"
host="45.129.78.228",
# host="127.0.0.1"
)
conn.autocommit = True
@@ -37,12 +39,6 @@ def insert_task(status, source_url=None, source_id=None, priority=0):
task_id = cur.fetchone()[0]
return task_id
def get_task(task_id):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM work_parser WHERE id = %s;", (task_id,))
task = cur.fetchone()
return task
def get_tasks_offset(limit, offset):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
@@ -54,6 +50,14 @@ def get_tasks_offset(limit, offset):
tasks = cur.fetchall()
return tasks
def delete_task(task_id: int):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("DELETE FROM work_parser WHERE id = %s RETURNING *;", (task_id,))
deleted_task = cur.fetchone()
if deleted_task:
return {"message": f"Задача {task_id} удалена", "deleted_task": dict(deleted_task)}
else:
return {"message": f"Задача с id {task_id} не найдена"}
def update_task(task_id, **fields):
# dynamic update query generator
@@ -72,16 +76,59 @@ def update_task(task_id, **fields):
cur.execute(f"UPDATE work_parser SET {set_sql} WHERE id = %s;", values)
return True
def create_table_config_gpt():
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS config_gpt (
url TEXT PRIMARY KEY,
name VARCHAR(20),
promt TEXT
);
""")
print("Таблица config_gpt создана или уже существует")
class Source (BaseModel):
url: HttpUrl
name: str
promt: str
def update_promt(data: Source):
if isinstance(data, dict):
data = Source.model_validate(data)
with conn.cursor() as cur:
cur.execute("""
INSERT INTO config_gpt (url, name, promt)
VALUES (%s, %s, %s)
ON CONFLICT (url) DO UPDATE SET
name = EXCLUDED.name,
promt = EXCLUDED.promt
""", (str(data.url), data.name, data.promt))
conn.commit()
def get_promt(url):
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT promt FROM config_gpt WHERE url = %s", (url,))
promt = cur.fetchone()
return promt['promt']
def get_all_promt():
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM config_gpt")
rows = cur.fetchall()
sources = [{"url": row["url"], "name": row["name"], "promt": row["promt"]} for row in rows]
return {"sources": sources}
# Пример использования
# if __name__ == "__main__":
# create_table()
# task_id = insert_task(status='queued', source_url='http://example.com', priority=5)
# print("Создана задача с id:", task_id)
# task = get_task(task_id)
# print("Получена задача:", task)
# update_task(task_id, status='in_progress', started_at=datetime.utcnow(), attempts=1)
# task = get_task(task_id)
# print("Обновленная задача:", task)
# create_table_config_gpt() # <-- раскомментировать эту строку
# update_promt({
# "url": "http://epaper.hljnews.cn/hljrb/pc/layout",
# "name": "source1",
# "promt": "Задача: Перевод на русский язык и тематическая фильтрация новостных статей из китайской прессы. \n Необходимо переводить текст статьи и определять, относится ли она к КНР по указанным темам: \n 1. Перевод\n Переведи предоставленный китайский текст на русский язык, сохранив оригинальный смысл, стиль и структуру.\n Текст:\n {content}\n -------------------------------------\n 2. Отбирай исключительно новости, прямо относящиеся к Китаю, его безопасности, соседним странам и территориям, влияющим на интересы Китая.\n Если не относится к Китаю — считаем, что статья НЕ подходит, и отдаем пустой JSON:\n {\"text\": \"\", \"pereskas\": \"\", \"title\": \"\", \"topics\": []}\n Если привязка есть — переходи к шагу 3. \n -------------------------------------\n 3. Тематическая классификация\n Определи, относится ли статья к одной или нескольким темам из списка:\n 1) Военные новости — конфликты, учения, мобилизация, закупки вооружений. \n 2) Пограничная деятельность — охрана границы, пограничные учения, строительство или модернизация пограничной инфраструктуры, техника для пограничников. \n 3) Пункты пропуска на границе с РФ — изменения режима работы, строительство, реконструкция, оборудование, логистика. \n 4) Пограничные реки — состояние рек, экология, инфраструктурные проекты, мониторинг. \n 5) Чрезвычайные ситуации — природные и техногенные происшествия, особенно затрагивающие пограничные реки и прилегающие земли. \n 6) Санитарно-эпидемиологическая обстановка — эпидемии, эпизоотии, эпифитотии, угрозы и меры предотвращения. \n 7) Индустриальные проекты (арктическое/антарктическое направление). \n 8) Индустриальные проекты в приграничных районах — заводы, производства, технопарки, новые технологии. \n 9) Инфраструктурные проекты в приграничных районах — дороги, мосты, транспорт, логистика. \n 10) Культура малочисленных народностей (нанайцы, монголы, уйгуры, нанайцы и хэчжэ) — политика, традиции, бытовая жизнь нанайцев, монголов, уйгуров, и хэчжэ (малочисленных народов).\n\n Отметь только те темы, которым статья действительно соответствует.\n\n -------------------------------------\n 4. Формат ответа \n Вернуть строго JSON без пояснений и дополнительных слов:\n {\n \"translation_text\": \"<перевод текста статьи на русский язык (дословный, точный и без сокращений ) >\",\n \"short_text\": \"<пересказ переведённого текста>\",\n \"title\": \"<краткая суть новости (12 предложения)>\",\n \"category\": \"<названий категорий, которым соответствует статья>\"\n }\n Если статья не относится ни к одной теме или не привязана к нужным регионам — вернуть:\n {\"translation_text\": \"\", \"short_text\": \"\", \"title\": \"\", \"category\": \"\"}"
# })