diff --git a/db/migrations/047_site_parser_schedule.sql b/db/migrations/047_site_parser_schedule.sql new file mode 100644 index 0000000..72af16d --- /dev/null +++ b/db/migrations/047_site_parser_schedule.sql @@ -0,0 +1,10 @@ +INSERT INTO app_settings(key, value_json, value_type, title, description, category) +VALUES ( + 'site_parser_default_interval_minutes', + '30'::jsonb, + 'int', + 'Интервал по умолчанию, мин', + 'Как часто проверять сайт, если у источника не задан свой интервал.', + 'Site Parser' +) +ON CONFLICT (key) DO NOTHING; diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 83c2be5..33bac20 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -25,7 +25,7 @@ from .config import settings from .constants import PLATFORM_SITE, PLATFORM_VK from .db import fetch_int_setting, fetch_setting, get_pool from .security import hash_password, new_token, token_hash, verify_password -from .source_adapters import validate_source_config +from .source_adapters import json_object, validate_source_config from .text_utils import build_publication_text, normalize_hash_tag, parse_categories from .vk_api import VKAPIClient, normalize_vk_source from .workers.ai_qualifier import AIQualifierWorker, normalize_model, response_usage @@ -366,6 +366,13 @@ SETTING_ORDER = { "parser_dedupe_content_hash", "parser_source_pause_sec", ], + "Site Parser": [ + "site_parser_url", + "site_parser_token", + "site_parser_rucaptcha_token", + "site_parser_timeout_sec", + "site_parser_default_interval_minutes", + ], "VK": [ "vk_requests_per_second", "vk_wall_page_size", @@ -2196,6 +2203,7 @@ async def sources_list(request: Request, q: str = "", status_filter: str = "", a sources = [] for row in rows: item = dict(row) + item["settings_json"] = json_object(item.get("settings_json")) item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at")) item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at")) item["created_at_fmt"] = format_dt(item.get("created_at")) @@ -2274,6 +2282,7 @@ async def sources_preview( sources = [] for row in rows: item = dict(row) + item["settings_json"] = json_object(item.get("settings_json")) item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at")) item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at")) item["created_at_fmt"] = format_dt(item.get("created_at")) @@ -2350,7 +2359,14 @@ async def source_new(request: Request): return redirect("/login") return templates.TemplateResponse( "source_form.html", - base_context(request, user, source=None, action="/sources/new", title="Новый источник"), + base_context( + request, + user, + source=None, + action="/sources/new", + title="Новый источник", + site_default_interval=await fetch_int_setting("site_parser_default_interval_minutes", 30), + ), ) @@ -2365,6 +2381,7 @@ async def source_create( active: str = Form("off"), priority: int = Form(100), settings_json: str = Form("{}"), + interval_minutes: int = Form(30), ): user = await get_current_user(request) if not user: @@ -2377,6 +2394,8 @@ async def source_create( source_settings = json.loads(settings_json or "{}") if not isinstance(source_settings, dict): raise ValueError("Настройки должны быть JSON-объектом") + if platform == PLATFORM_SITE: + source_settings["interval_minutes"] = interval_minutes validate_source_config(platform, source_settings) except (json.JSONDecodeError, ValueError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @@ -2439,9 +2458,10 @@ async def source_edit(request: Request, source_id: int): base_context( request, user, - source=dict(source), + source={**dict(source), "settings_json": json_object(source["settings_json"])}, action=f"/sources/{source_id}/edit", title=f"Источник #{source_id}", + site_default_interval=await fetch_int_setting("site_parser_default_interval_minutes", 30), ), ) @@ -2458,6 +2478,7 @@ async def source_update( active: str = Form("off"), priority: int = Form(100), settings_json: str = Form("{}"), + interval_minutes: int = Form(30), ): user = await get_current_user(request) if not user: @@ -2470,6 +2491,8 @@ async def source_update( source_settings = json.loads(settings_json or "{}") if not isinstance(source_settings, dict): raise ValueError("Настройки должны быть JSON-объектом") + if platform == PLATFORM_SITE: + source_settings["interval_minutes"] = interval_minutes validate_source_config(platform, source_settings) except (json.JSONDecodeError, ValueError) as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @@ -2532,6 +2555,7 @@ async def source_toggle(request: Request, source_id: int, csrf_token: str = Form source_row = await pool.fetchrow("SELECT * FROM sources WHERE id=$1", source_id) if source_row: s = dict(source_row) + s["settings_json"] = json_object(s.get("settings_json")) # Fetch stats just for this source to render correctly stats = await pool.fetchrow( """ @@ -2549,6 +2573,25 @@ async def source_toggle(request: Request, source_id: int, csrf_token: str = Form return redirect("/sources") +@app.post("/sources/{source_id}/parse-now") +async def source_parse_now(request: Request, source_id: int, csrf_token: str = Form(...)): + user = await get_current_user(request) + if not user: + return redirect("/login") + require_csrf(user, csrf_token) + pool = await get_pool() + await pool.execute( + """ + UPDATE sources + SET last_checked_at=NULL, status='new', status_msg=NULL, updated_at=NOW() + WHERE id=$1 AND archived_at IS NULL + """, + source_id, + ) + await audit(user["id"], "source.parse_now", "source", source_id) + return redirect("/sources") + + @app.post("/sources/{source_id}/delete") async def source_delete(request: Request, source_id: int, csrf_token: str = Form(...)): user = await get_current_user(request) @@ -3546,6 +3589,18 @@ async def workers(request: Request): continue settings.append(item) settings.sort(key=setting_sort_key) + site_parser_url = str(setting_values.get("site_parser_url") or "").strip().rstrip("/") + site_parser_health = {"ok": False, "message": "URL не настроен"} + if site_parser_url: + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session: + async with session.get(f"{site_parser_url}/health") as response: + site_parser_health = { + "ok": response.status == 200, + "message": "Доступен" if response.status == 200 else f"HTTP {response.status}", + } + except Exception as exc: + site_parser_health = {"ok": False, "message": str(exc)[:200] or "Недоступен"} ai_provider = str(setting_value(settings, "ai_qualifier_provider", "openrouter") or "openrouter") ai_model = str(setting_value(settings, "ai_qualifier_model", "") or "") ai_api_key = str(setting_value(settings, "ai_qualifier_api_key", "") or "") @@ -3580,6 +3635,8 @@ async def workers(request: Request): for r in rows ], settings=settings, + site_parser_url=site_parser_url, + site_parser_health=site_parser_health, provider_options=PROVIDER_OPTIONS, ai_provider=ai_provider, ai_model=ai_model, diff --git a/src/vk_parser_app/source_adapters.py b/src/vk_parser_app/source_adapters.py index 87690e6..09c132d 100644 --- a/src/vk_parser_app/source_adapters.py +++ b/src/vk_parser_app/source_adapters.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any @@ -9,6 +10,18 @@ import aiohttp from .constants import PLATFORM_SITE, PLATFORM_VK +def json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + @dataclass class SourceMedia: url: str @@ -42,6 +55,12 @@ def validate_source_config(platform: str, config: dict[str, Any]) -> None: raise ValueError("max_items должен быть целым числом") from exc if not 1 <= max_items <= 100: raise ValueError("max_items должен быть от 1 до 100") + try: + interval_minutes = int(config.get("interval_minutes", 30)) + except (TypeError, ValueError) as exc: + raise ValueError("interval_minutes должен быть целым числом") from exc + if not 1 <= interval_minutes <= 10080: + raise ValueError("interval_minutes должен быть от 1 до 10080") def _posted_at(value: Any) -> datetime: @@ -72,9 +91,9 @@ class SiteParserClient: async def fetch(self, source: dict) -> tuple[list[SourceItem], dict[str, Any] | None]: if not self.base_url or not self.token: raise RuntimeError("Site Parser URL или токен не настроены") - config = dict(source.get("settings_json") or {}) + config = json_object(source.get("settings_json")) validate_source_config(PLATFORM_SITE, config) - runtime_state = dict(source.get("runtime_state_json") or {}) + runtime_state = json_object(source.get("runtime_state_json")) payload = { "url": source["url"], "config": config, diff --git a/src/vk_parser_app/templates/source_form.html b/src/vk_parser_app/templates/source_form.html index d6bc697..f35698a 100644 --- a/src/vk_parser_app/templates/source_form.html +++ b/src/vk_parser_app/templates/source_form.html @@ -42,6 +42,8 @@
{
"format": "rss",
"access": "auto",
- "max_items": 20
+ "max_items": 20,
+ "interval_minutes": 30
}
access: auto сначала пробует обычный запрос и при Cloudflare использует RuCaptcha; http запрещает браузер; cloudflare сразу запускает браузер.