feat: filter site bodies before media upload
This commit is contained in:
+12
-18
@@ -61,7 +61,7 @@
|
|||||||
│ ├── templates/ # HTML-шаблоны
|
│ ├── templates/ # HTML-шаблоны
|
||||||
│ └── workers/
|
│ └── workers/
|
||||||
│ ├── parser.py # VK-парсер
|
│ ├── parser.py # VK-парсер
|
||||||
│ ├── vk_storage_uploader.py# Telegram media storage uploader
|
│ ├── media_uploader.py# Telegram media storage uploader
|
||||||
│ ├── ai_qualifier.py # AI-квалификатор
|
│ ├── ai_qualifier.py # AI-квалификатор
|
||||||
│ └── ai_writer.py # AI-райтер
|
│ └── ai_writer.py # AI-райтер
|
||||||
├── original_project/ # старый проект как справочник
|
├── original_project/ # старый проект как справочник
|
||||||
@@ -338,7 +338,7 @@ AI-райтер:
|
|||||||
|
|
||||||
Сейчас основной тип:
|
Сейчас основной тип:
|
||||||
|
|
||||||
- `vk.storage.copy`
|
- `media.storage.copy`
|
||||||
|
|
||||||
Название историческое: сначала планировался VK storage, потом медиа-сторедж вернулся в Telegram. Тип задачи пока не переименован.
|
Название историческое: сначала планировался VK storage, потом медиа-сторедж вернулся в Telegram. Тип задачи пока не переименован.
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ AI-райтер:
|
|||||||
Имена воркеров:
|
Имена воркеров:
|
||||||
|
|
||||||
- `vk-parser`
|
- `vk-parser`
|
||||||
- `vk-storage-uploader`
|
- `media-uploader`
|
||||||
- `ai-qualifier`
|
- `ai-qualifier`
|
||||||
- `ai-writer`
|
- `ai-writer`
|
||||||
- `tg-poster`
|
- `tg-poster`
|
||||||
@@ -556,7 +556,7 @@ Retention: записи старше 30 дней удаляются при ст
|
|||||||
6. При включенном `parser_dedupe_content_hash` также убирает дубли по `content_hash`.
|
6. При включенном `parser_dedupe_content_hash` также убирает дубли по `content_hash`.
|
||||||
7. Применяет политику мусорных постов.
|
7. Применяет политику мусорных постов.
|
||||||
8. Сохраняет raw post и media.
|
8. Сохраняет raw post и media.
|
||||||
9. Создаёт job `vk.storage.copy`.
|
9. Создаёт job `media.storage.copy`.
|
||||||
|
|
||||||
### 7.2. Политика мусорных постов
|
### 7.2. Политика мусорных постов
|
||||||
|
|
||||||
@@ -615,13 +615,13 @@ Retention: записи старше 30 дней удаляются при ст
|
|||||||
|
|
||||||
## 8. Media storage uploader
|
## 8. Media storage uploader
|
||||||
|
|
||||||
Файл: `src/vk_parser_app/workers/vk_storage_uploader.py`.
|
Файл: `src/vk_parser_app/workers/media_uploader.py`.
|
||||||
|
|
||||||
Название файла историческое: сейчас фактическое хранилище медиа - Telegram, а не VK.
|
Название файла историческое: сейчас фактическое хранилище медиа - Telegram, а не VK.
|
||||||
|
|
||||||
### 8.1. Что делает
|
### 8.1. Что делает
|
||||||
|
|
||||||
1. Забирает job типа `vk.storage.copy`.
|
1. Забирает job типа `media.storage.copy`.
|
||||||
2. Загружает `raw_post` и media.
|
2. Загружает `raw_post` и media.
|
||||||
3. Скачивает изображения/видео.
|
3. Скачивает изображения/видео.
|
||||||
4. Отправляет в Telegram storage channel.
|
4. Отправляет в Telegram storage channel.
|
||||||
@@ -1247,7 +1247,7 @@ uvicorn vk_parser_app.admin:app --host 0.0.0.0 --port 8080
|
|||||||
```powershell
|
```powershell
|
||||||
$env:PYTHONPATH="src"
|
$env:PYTHONPATH="src"
|
||||||
python -m vk_parser_app.workers.parser
|
python -m vk_parser_app.workers.parser
|
||||||
python -m vk_parser_app.workers.vk_storage_uploader
|
python -m vk_parser_app.workers.media_uploader
|
||||||
python -m vk_parser_app.workers.ai_qualifier
|
python -m vk_parser_app.workers.ai_qualifier
|
||||||
python -m vk_parser_app.workers.ai_writer
|
python -m vk_parser_app.workers.ai_writer
|
||||||
python -m vk_parser_app.workers.tg_poster
|
python -m vk_parser_app.workers.tg_poster
|
||||||
@@ -1376,16 +1376,10 @@ systemctl start ai-writer.service
|
|||||||
|
|
||||||
Они не влияют на текущую очередь, но могут путать в админке. Их стоит аккуратно пометить как failed/cancelled или скрывать служебные тесты.
|
Они не влияют на текущую очередь, но могут путать в админке. Их стоит аккуратно пометить как failed/cancelled или скрывать служебные тесты.
|
||||||
|
|
||||||
### 20.2. Название `vk_storage_uploader.py`
|
### 20.2. Media uploader
|
||||||
|
|
||||||
Файл исторически называется VK storage uploader, но фактически загружает в Telegram. Можно позже переименовать:
|
Uploader переименован в `media_uploader.py`, worker — в `media-uploader`, а jobs — в
|
||||||
|
`media.storage.copy`. Миграция сохраняет уже созданные задания.
|
||||||
```text
|
|
||||||
vk_storage_uploader.py -> tg_storage_uploader.py
|
|
||||||
JOB_TYPE_VK_STORAGE_COPY -> tg.storage.copy
|
|
||||||
```
|
|
||||||
|
|
||||||
Делать только миграцией и аккуратно, чтобы не потерять jobs.
|
|
||||||
|
|
||||||
### 20.3. Настройки Telegram uploader
|
### 20.3. Настройки Telegram uploader
|
||||||
|
|
||||||
@@ -1449,7 +1443,7 @@ JOB_TYPE_VK_STORAGE_COPY -> tg.storage.copy
|
|||||||
sources
|
sources
|
||||||
-> parser
|
-> parser
|
||||||
-> raw_posts + raw_post_media
|
-> raw_posts + raw_post_media
|
||||||
-> jobs(vk.storage.copy)
|
-> jobs(media.storage.copy)
|
||||||
-> Telegram storage uploader
|
-> Telegram storage uploader
|
||||||
-> status=storage_ready
|
-> status=storage_ready
|
||||||
-> AI qualifier
|
-> AI qualifier
|
||||||
@@ -1485,7 +1479,7 @@ admin_sessions
|
|||||||
```text
|
```text
|
||||||
src/vk_parser_app/admin.py
|
src/vk_parser_app/admin.py
|
||||||
src/vk_parser_app/workers/parser.py
|
src/vk_parser_app/workers/parser.py
|
||||||
src/vk_parser_app/workers/vk_storage_uploader.py
|
src/vk_parser_app/workers/media_uploader.py
|
||||||
src/vk_parser_app/workers/ai_qualifier.py
|
src/vk_parser_app/workers/ai_qualifier.py
|
||||||
src/vk_parser_app/workers/ai_writer.py
|
src/vk_parser_app/workers/ai_writer.py
|
||||||
src/vk_parser_app/workers/tg_poster.py
|
src/vk_parser_app/workers/tg_poster.py
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Database settings:
|
|||||||
As last checked on 2026-08-03:
|
As last checked on 2026-08-03:
|
||||||
|
|
||||||
- `vk-parser=true`
|
- `vk-parser=true`
|
||||||
- `vk-storage-uploader=true`
|
- `media-uploader=true`
|
||||||
- `ai-qualifier=false`
|
- `ai-qualifier=false`
|
||||||
- `ai-writer=false`
|
- `ai-writer=false`
|
||||||
- `tg-poster=false`
|
- `tg-poster=false`
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ https://vk.com/wall-239548476_123
|
|||||||
"access": "cloudflare",
|
"access": "cloudflare",
|
||||||
"max_items": 20,
|
"max_items": 20,
|
||||||
"follow_links": true,
|
"follow_links": true,
|
||||||
"content_selector": "article.full"
|
"content_selector": "article.full",
|
||||||
|
"text_selector": ".field--name-body",
|
||||||
|
"min_text_length": 50
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -162,7 +164,7 @@ PYTHONPATH=src .venv/bin/python -m vk_parser_app.workers.parser
|
|||||||
VK storage uploader:
|
VK storage uploader:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=src .venv/bin/python -m vk_parser_app.workers.vk_storage_uploader
|
PYTHONPATH=src .venv/bin/python -m vk_parser_app.workers.media_uploader
|
||||||
```
|
```
|
||||||
|
|
||||||
## systemd units
|
## systemd units
|
||||||
@@ -203,17 +205,17 @@ RestartSec=10
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
`vk-storage-uploader.service`:
|
`media-uploader.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=VK Storage Uploader Worker
|
Description=Media Uploader Worker
|
||||||
After=network.target postgresql.service
|
After=network.target postgresql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=/opt/vk-parser
|
WorkingDirectory=/opt/vk-parser
|
||||||
Environment=PYTHONPATH=/opt/vk-parser/src
|
Environment=PYTHONPATH=/opt/vk-parser/src
|
||||||
ExecStart=/opt/vk-parser/.venv/bin/python -m vk_parser_app.workers.vk_storage_uploader
|
ExecStart=/opt/vk-parser/.venv/bin/python -m vk_parser_app.workers.media_uploader
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
@@ -224,13 +226,13 @@ WantedBy=multi-user.target
|
|||||||
## Текущий pipeline
|
## Текущий pipeline
|
||||||
|
|
||||||
```text
|
```text
|
||||||
sources(vk)
|
sources(vk/site)
|
||||||
-> vk-parser
|
-> vk-parser
|
||||||
-> raw_posts + raw_post_media
|
-> raw_posts + raw_post_media
|
||||||
-> jobs(type='vk.storage.copy')
|
-> jobs(type='media.storage.copy')
|
||||||
-> vk-storage-uploader
|
-> media-uploader
|
||||||
-> wall.post в storage-группе
|
-> локальный Telegram Bot API
|
||||||
-> raw_posts.storage_post_url
|
-> raw_posts.storage_post_url
|
||||||
```
|
```
|
||||||
|
|
||||||
Фото копируются в storage-группу. Видео в первой версии сохраняются как `link_only`/исходное VK-вложение, чтобы сначала стабилизировать основной контур.
|
Фото и видео загружаются в Telegram media storage; исходные URL остаются в `raw_post_media`.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Current runtime is local infrastructure, not the old VPS.
|
|||||||
- Templates: `src/vk_parser_app/templates`
|
- Templates: `src/vk_parser_app/templates`
|
||||||
- Workers:
|
- Workers:
|
||||||
- VK parser: `src/vk_parser_app/workers/parser.py`
|
- VK parser: `src/vk_parser_app/workers/parser.py`
|
||||||
- Telegram media/storage uploader: `src/vk_parser_app/workers/vk_storage_uploader.py`
|
- Telegram media/storage uploader: `src/vk_parser_app/workers/media_uploader.py`
|
||||||
- AI qualifier: `src/vk_parser_app/workers/ai_qualifier.py`
|
- AI qualifier: `src/vk_parser_app/workers/ai_qualifier.py`
|
||||||
- AI writer: `src/vk_parser_app/workers/ai_writer.py`
|
- AI writer: `src/vk_parser_app/workers/ai_writer.py`
|
||||||
- DB migrations: `db/migrations`
|
- DB migrations: `db/migrations`
|
||||||
@@ -66,7 +66,7 @@ canonical git repository as FN-8. Code work for both projects must happen in
|
|||||||
- VK publication owner: `vk_poster_owner_id=-36860851`, `vk_poster_from_group=true`.
|
- VK publication owner: `vk_poster_owner_id=-36860851`, `vk_poster_from_group=true`.
|
||||||
- `TG_BOT_TOKEN`, `TELEGRAM_API_ID`, and `TELEGRAM_API_HASH` were copied from FN-8 Coolify env.
|
- `TG_BOT_TOKEN`, `TELEGRAM_API_ID`, and `TELEGRAM_API_HASH` were copied from FN-8 Coolify env.
|
||||||
- Local Bot API app setting: `local_bot_api_url=http://127.0.0.1:8081`.
|
- Local Bot API app setting: `local_bot_api_url=http://127.0.0.1:8081`.
|
||||||
- As last checked, RAA has `vk-parser=true`, `vk-storage-uploader=true`.
|
- As last checked, RAA has `vk-parser=true`, `media-uploader=true`.
|
||||||
AI and publication workers are intentionally disabled until explicitly started:
|
AI and publication workers are intentionally disabled until explicitly started:
|
||||||
`ai-qualifier=false`, `ai-writer=false`, `tg-poster=false`, `vk-poster=false`,
|
`ai-qualifier=false`, `ai-writer=false`, `tg-poster=false`, `vk-poster=false`,
|
||||||
`site-poster=false`, `tg_poster_enabled=false`, `vk_poster_enabled=false`.
|
`site-poster=false`, `tg_poster_enabled=false`, `vk_poster_enabled=false`.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
|
||||||
|
VALUES (
|
||||||
|
'site_parser_min_text_length',
|
||||||
|
'50'::jsonb,
|
||||||
|
'int',
|
||||||
|
'Минимальная длина текста сайта',
|
||||||
|
'Материалы сайта с более коротким телом не попадают в raw и media uploader.',
|
||||||
|
'Site Parser'
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
UPDATE jobs
|
||||||
|
SET type='media.storage.copy',
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE type='vk.storage.copy';
|
||||||
|
|
||||||
|
UPDATE worker_controls
|
||||||
|
SET name='media-uploader',
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE name='vk-storage-uploader';
|
||||||
|
|
||||||
|
UPDATE worker_heartbeats
|
||||||
|
SET name='media-uploader',
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE name='vk-storage-uploader';
|
||||||
@@ -11,14 +11,14 @@ from aiogram.types import FSInputFile
|
|||||||
|
|
||||||
from vk_parser_app.constants import MEDIA_STATUS_FAILED, MEDIA_STATUS_LINK_ONLY
|
from vk_parser_app.constants import MEDIA_STATUS_FAILED, MEDIA_STATUS_LINK_ONLY
|
||||||
from vk_parser_app.db import get_pool
|
from vk_parser_app.db import get_pool
|
||||||
from vk_parser_app.workers.vk_storage_uploader import TMP_DIR, TMP_PREFIX, TelegramStorageUploader
|
from vk_parser_app.workers.media_uploader import TMP_DIR, TMP_PREFIX, MediaUploader
|
||||||
|
|
||||||
|
|
||||||
def jlog(event: str, **payload: Any) -> None:
|
def jlog(event: str, **payload: Any) -> None:
|
||||||
print(json.dumps({"event": event, **payload}, ensure_ascii=False, default=str), flush=True)
|
print(json.dumps({"event": event, **payload}, ensure_ascii=False, default=str), flush=True)
|
||||||
|
|
||||||
|
|
||||||
async def load_pending_videos(worker: TelegramStorageUploader, limit: int) -> list[dict[str, Any]]:
|
async def load_pending_videos(worker: MediaUploader, limit: int) -> list[dict[str, Any]]:
|
||||||
rows = await worker.pool.fetch(
|
rows = await worker.pool.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT m.*, rp.original_url AS post_url
|
SELECT m.*, rp.original_url AS post_url
|
||||||
@@ -38,7 +38,7 @@ async def load_pending_videos(worker: TelegramStorageUploader, limit: int) -> li
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def send_video_to_storage(worker: TelegramStorageUploader, media: dict[str, Any], path: str) -> None:
|
async def send_video_to_storage(worker: MediaUploader, media: dict[str, Any], path: str) -> None:
|
||||||
caption = (
|
caption = (
|
||||||
f"Backfill video media #{int(media['id'])} for raw_post #{int(media['raw_post_id'])}\n"
|
f"Backfill video media #{int(media['id'])} for raw_post #{int(media['raw_post_id'])}\n"
|
||||||
f'<a href="{html.escape(str(media.get("post_url") or ""), quote=True)}">source post</a>'
|
f'<a href="{html.escape(str(media.get("post_url") or ""), quote=True)}">source post</a>'
|
||||||
@@ -58,7 +58,7 @@ async def send_video_to_storage(worker: TelegramStorageUploader, media: dict[str
|
|||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
limit = int(os.getenv("BACKFILL_LIMIT", "200"))
|
limit = int(os.getenv("BACKFILL_LIMIT", "200"))
|
||||||
worker = TelegramStorageUploader()
|
worker = MediaUploader()
|
||||||
await worker.init()
|
await worker.init()
|
||||||
videos = await load_pending_videos(worker, limit)
|
videos = await load_pending_videos(worker, limit)
|
||||||
jlog("start", count=len(videos), limit=limit)
|
jlog("start", count=len(videos), limit=limit)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ async def enrich_items_in_browser(
|
|||||||
rucaptcha_token: str,
|
rucaptcha_token: str,
|
||||||
browser_state: dict[str, Any] | None,
|
browser_state: dict[str, Any] | None,
|
||||||
content_selector: str,
|
content_selector: str,
|
||||||
|
text_selector: str,
|
||||||
) -> tuple[list[ParsedItem], dict[str, Any]]:
|
) -> tuple[list[ParsedItem], dict[str, Any]]:
|
||||||
async with browser_lock:
|
async with browser_lock:
|
||||||
async with async_playwright() as playwright:
|
async with async_playwright() as playwright:
|
||||||
@@ -194,9 +195,11 @@ async def enrich_items_in_browser(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(f"Item content not found: {item.url} ({content_selector})") from exc
|
raise RuntimeError(f"Item content not found: {item.url} ({content_selector})") from exc
|
||||||
html, text, media = clean_html(await content.inner_html(), item.url)
|
html, text, media = clean_html(await content.inner_html(), item.url)
|
||||||
if text:
|
if text_selector:
|
||||||
item.html = html
|
text_content = content.locator(text_selector).first
|
||||||
item.text = text
|
text = clean_html(await text_content.inner_html())[1] if await text_content.count() else ""
|
||||||
|
item.html = html
|
||||||
|
item.text = text
|
||||||
item.media = list({entry["url"]: entry for entry in [*item.media, *media]}.values())
|
item.media = list({entry["url"]: entry for entry in [*item.media, *media]}.values())
|
||||||
state = await context.storage_state()
|
state = await context.storage_state()
|
||||||
await browser.close()
|
await browser.close()
|
||||||
@@ -232,6 +235,7 @@ async def parse_source(
|
|||||||
if not isinstance(follow_links, bool):
|
if not isinstance(follow_links, bool):
|
||||||
raise HTTPException(status_code=422, detail="config.follow_links must be true or false")
|
raise HTTPException(status_code=422, detail="config.follow_links must be true or false")
|
||||||
content_selector = str(config.get("content_selector") or "").strip()
|
content_selector = str(config.get("content_selector") or "").strip()
|
||||||
|
text_selector = str(config.get("text_selector") or "").strip()
|
||||||
if follow_links and not content_selector:
|
if follow_links and not content_selector:
|
||||||
raise HTTPException(status_code=422, detail="config.content_selector is required when follow_links=true")
|
raise HTTPException(status_code=422, detail="config.content_selector is required when follow_links=true")
|
||||||
url = str(request.url)
|
url = str(request.url)
|
||||||
@@ -273,6 +277,7 @@ async def parse_source(
|
|||||||
request.rucaptcha_token.get_secret_value(),
|
request.rucaptcha_token.get_secret_value(),
|
||||||
state,
|
state,
|
||||||
content_selector,
|
content_selector,
|
||||||
|
text_selector,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from .workers.site_poster import SitePoster
|
|||||||
from .workers.tg_poster import TelegramPoster
|
from .workers.tg_poster import TelegramPoster
|
||||||
from .workers.tg_reactor import TelegramReactor
|
from .workers.tg_reactor import TelegramReactor
|
||||||
from .workers.vk_poster import VKPoster
|
from .workers.vk_poster import VKPoster
|
||||||
from .workers.vk_storage_uploader import TelegramStorageUploader
|
from .workers.media_uploader import MediaUploader
|
||||||
|
|
||||||
COOKIE_NAME = "vk_parser_admin"
|
COOKIE_NAME = "vk_parser_admin"
|
||||||
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
||||||
@@ -372,6 +372,7 @@ SETTING_ORDER = {
|
|||||||
"site_parser_rucaptcha_token",
|
"site_parser_rucaptcha_token",
|
||||||
"site_parser_timeout_sec",
|
"site_parser_timeout_sec",
|
||||||
"site_parser_interval_minutes",
|
"site_parser_interval_minutes",
|
||||||
|
"site_parser_min_text_length",
|
||||||
],
|
],
|
||||||
"VK": [
|
"VK": [
|
||||||
"vk_requests_per_second",
|
"vk_requests_per_second",
|
||||||
@@ -1820,7 +1821,7 @@ async def startup() -> None:
|
|||||||
("tg-poster", TelegramPoster()),
|
("tg-poster", TelegramPoster()),
|
||||||
("tg-reactor", TelegramReactor()),
|
("tg-reactor", TelegramReactor()),
|
||||||
("vk-poster", VKPoster()),
|
("vk-poster", VKPoster()),
|
||||||
("vk-storage-uploader", TelegramStorageUploader()),
|
("media-uploader", MediaUploader()),
|
||||||
]
|
]
|
||||||
for name, worker in workers:
|
for name, worker in workers:
|
||||||
asyncio.create_task(start_worker_task(worker, name))
|
asyncio.create_task(start_worker_task(worker, name))
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ JOB_STATUS_RETRY = "retry"
|
|||||||
JOB_STATUS_DONE = "done"
|
JOB_STATUS_DONE = "done"
|
||||||
JOB_STATUS_DEAD = "dead"
|
JOB_STATUS_DEAD = "dead"
|
||||||
|
|
||||||
JOB_TYPE_VK_STORAGE_COPY = "vk.storage.copy"
|
JOB_TYPE_MEDIA_STORAGE_COPY = "media.storage.copy"
|
||||||
|
|
||||||
WORKER_PARSER = "vk-parser"
|
WORKER_PARSER = "vk-parser"
|
||||||
WORKER_STORAGE_UPLOADER = "vk-storage-uploader"
|
WORKER_MEDIA_UPLOADER = "media-uploader"
|
||||||
WORKER_AI_QUALIFIER = "ai-qualifier"
|
WORKER_AI_QUALIFIER = "ai-qualifier"
|
||||||
WORKER_AI_WRITER = "ai-writer"
|
WORKER_AI_WRITER = "ai-writer"
|
||||||
WORKER_TG_POSTER = "tg-poster"
|
WORKER_TG_POSTER = "tg-poster"
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class SourceItem:
|
|||||||
media: list[SourceMedia] = field(default_factory=list)
|
media: list[SourceMedia] = field(default_factory=list)
|
||||||
raw: dict[str, Any] = field(default_factory=dict)
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def body_text(self) -> str:
|
||||||
|
return str(self.raw.get("text") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def validate_source_config(platform: str, config: dict[str, Any]) -> None:
|
def validate_source_config(platform: str, config: dict[str, Any]) -> None:
|
||||||
if platform == PLATFORM_VK:
|
if platform == PLATFORM_VK:
|
||||||
@@ -60,6 +64,12 @@ def validate_source_config(platform: str, config: dict[str, Any]) -> None:
|
|||||||
raise ValueError("follow_links должен быть true или false")
|
raise ValueError("follow_links должен быть true или false")
|
||||||
if follow_links and not str(config.get("content_selector") or "").strip():
|
if follow_links and not str(config.get("content_selector") or "").strip():
|
||||||
raise ValueError("При follow_links=true нужен content_selector")
|
raise ValueError("При follow_links=true нужен content_selector")
|
||||||
|
try:
|
||||||
|
min_text_length = int(config.get("min_text_length", 0))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("min_text_length должен быть целым числом") from exc
|
||||||
|
if not 0 <= min_text_length <= 100_000:
|
||||||
|
raise ValueError("min_text_length должен быть от 0 до 100000")
|
||||||
|
|
||||||
|
|
||||||
def _posted_at(value: Any) -> datetime:
|
def _posted_at(value: Any) -> datetime:
|
||||||
|
|||||||
@@ -52,7 +52,8 @@
|
|||||||
"max_items": 20
|
"max_items": 20
|
||||||
}</pre>
|
}</pre>
|
||||||
<p class="mt-2"><code>access</code>: <code>auto</code> сначала пробует обычный запрос и при Cloudflare использует RuCaptcha; <code>http</code> запрещает браузер; <code>cloudflare</code> сразу запускает браузер.</p>
|
<p class="mt-2"><code>access</code>: <code>auto</code> сначала пробует обычный запрос и при Cloudflare использует RuCaptcha; <code>http</code> запрещает браузер; <code>cloudflare</code> сразу запускает браузер.</p>
|
||||||
<p class="mt-2">Если RSS не содержит текст или медиа, добавьте <code>"follow_links": true</code> и CSS-селектор содержимого страницы, например <code>"content_selector": "article.full"</code>.</p>
|
<p class="mt-2">Если RSS не содержит текст или медиа, добавьте <code>"follow_links": true</code>, селектор материала <code>"content_selector": "article.full"</code> и при необходимости отдельный селектор текста <code>"text_selector": ".field--name-body"</code>.</p>
|
||||||
|
<p class="mt-2"><code>min_text_length</code> переопределяет общий порог Site Parser только для этого источника.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -19,13 +19,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
JOB_TYPE_VK_STORAGE_COPY,
|
JOB_TYPE_MEDIA_STORAGE_COPY,
|
||||||
MEDIA_STATUS_FAILED,
|
MEDIA_STATUS_FAILED,
|
||||||
MEDIA_STATUS_LINK_ONLY,
|
MEDIA_STATUS_LINK_ONLY,
|
||||||
MEDIA_STATUS_UPLOADED,
|
MEDIA_STATUS_UPLOADED,
|
||||||
POST_STATUS_FAILED,
|
POST_STATUS_FAILED,
|
||||||
POST_STATUS_STORAGE_READY,
|
POST_STATUS_STORAGE_READY,
|
||||||
WORKER_STORAGE_UPLOADER,
|
WORKER_MEDIA_UPLOADER,
|
||||||
)
|
)
|
||||||
from ..db import fetch_float_setting, fetch_int_setting, fetch_setting, get_pool
|
from ..db import fetch_float_setting, fetch_int_setting, fetch_setting, get_pool
|
||||||
from ..heartbeat import HeartbeatReporter
|
from ..heartbeat import HeartbeatReporter
|
||||||
@@ -90,13 +90,13 @@ def original_link(post: dict) -> str:
|
|||||||
return f'<a href="{url}">#{int(post["id"])}</a>' if url else f'#{int(post["id"])}'
|
return f'<a href="{url}">#{int(post["id"])}</a>' if url else f'#{int(post["id"])}'
|
||||||
|
|
||||||
|
|
||||||
class TelegramStorageUploader:
|
class MediaUploader:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.pool = None
|
self.pool = None
|
||||||
self.bot: Bot | None = None
|
self.bot: Bot | None = None
|
||||||
self.storage_chat_id: int | None = None
|
self.storage_chat_id: int | None = None
|
||||||
self.storage_thread_id: int | None = None
|
self.storage_thread_id: int | None = None
|
||||||
self.heartbeat = HeartbeatReporter(WORKER_STORAGE_UPLOADER, 30)
|
self.heartbeat = HeartbeatReporter(WORKER_MEDIA_UPLOADER, 30)
|
||||||
self.media_group_max_items = MAX_MEDIA_GROUP
|
self.media_group_max_items = MAX_MEDIA_GROUP
|
||||||
self.media_upload_delay_sec = 1.0
|
self.media_upload_delay_sec = 1.0
|
||||||
self.tg_retry_max_attempts = 4
|
self.tg_retry_max_attempts = 4
|
||||||
@@ -115,7 +115,7 @@ class TelegramStorageUploader:
|
|||||||
|
|
||||||
async def init(self) -> None:
|
async def init(self) -> None:
|
||||||
self.pool = await get_pool()
|
self.pool = await get_pool()
|
||||||
recovered = await recover_stale_jobs(self.pool, JOB_TYPE_VK_STORAGE_COPY, stale_minutes=20)
|
recovered = await recover_stale_jobs(self.pool, JOB_TYPE_MEDIA_STORAGE_COPY, stale_minutes=20)
|
||||||
if recovered:
|
if recovered:
|
||||||
logger.warning("Recovered stale storage jobs: {}", recovered)
|
logger.warning("Recovered stale storage jobs: {}", recovered)
|
||||||
|
|
||||||
@@ -660,12 +660,12 @@ class TelegramStorageUploader:
|
|||||||
logger.info("Telegram storage done: raw_post={} messages={}", raw_post_id, message_ids)
|
logger.info("Telegram storage done: raw_post={} messages={}", raw_post_id, message_ids)
|
||||||
|
|
||||||
async def run_once(self, worker_id: str) -> bool:
|
async def run_once(self, worker_id: str) -> bool:
|
||||||
enabled = await is_worker_enabled(self.pool, WORKER_STORAGE_UPLOADER)
|
enabled = await is_worker_enabled(self.pool, WORKER_MEDIA_UPLOADER)
|
||||||
if not enabled:
|
if not enabled:
|
||||||
await self.heartbeat.beat(self.pool, status="disabled", force=True)
|
await self.heartbeat.beat(self.pool, status="disabled", force=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
job = await claim_job(self.pool, JOB_TYPE_VK_STORAGE_COPY, worker_id)
|
job = await claim_job(self.pool, JOB_TYPE_MEDIA_STORAGE_COPY, worker_id)
|
||||||
if not job:
|
if not job:
|
||||||
await self.heartbeat.beat(self.pool, status="idle")
|
await self.heartbeat.beat(self.pool, status="idle")
|
||||||
return False
|
return False
|
||||||
@@ -688,7 +688,7 @@ class TelegramStorageUploader:
|
|||||||
|
|
||||||
async def run_loop(self) -> None:
|
async def run_loop(self) -> None:
|
||||||
await self.init()
|
await self.init()
|
||||||
worker_id = f"{WORKER_STORAGE_UPLOADER}:{os.getpid()}"
|
worker_id = f"{WORKER_MEDIA_UPLOADER}:{os.getpid()}"
|
||||||
logger.info("{} started", worker_id)
|
logger.info("{} started", worker_id)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -705,7 +705,7 @@ class TelegramStorageUploader:
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
logger.remove()
|
logger.remove()
|
||||||
logger.add(sys.stdout, level=settings.log_level)
|
logger.add(sys.stdout, level=settings.log_level)
|
||||||
worker = TelegramStorageUploader()
|
worker = MediaUploader()
|
||||||
await worker.run_loop()
|
await worker.run_loop()
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
JOB_TYPE_VK_STORAGE_COPY,
|
JOB_TYPE_MEDIA_STORAGE_COPY,
|
||||||
PLATFORM_SITE,
|
PLATFORM_SITE,
|
||||||
PLATFORM_VK,
|
PLATFORM_VK,
|
||||||
POST_STATUS_SKIPPED,
|
POST_STATUS_SKIPPED,
|
||||||
@@ -22,7 +22,7 @@ from ..constants import (
|
|||||||
from ..db import fetch_bool_setting, fetch_float_setting, fetch_int_setting, fetch_setting, get_pool
|
from ..db import fetch_bool_setting, fetch_float_setting, fetch_int_setting, fetch_setting, get_pool
|
||||||
from ..heartbeat import HeartbeatReporter
|
from ..heartbeat import HeartbeatReporter
|
||||||
from ..jobs import is_worker_enabled
|
from ..jobs import is_worker_enabled
|
||||||
from ..source_adapters import SiteParserClient, SourceItem
|
from ..source_adapters import SiteParserClient, SourceItem, json_object
|
||||||
from ..vk_api import (
|
from ..vk_api import (
|
||||||
VKAPIClient,
|
VKAPIClient,
|
||||||
VKAPIError,
|
VKAPIError,
|
||||||
@@ -223,7 +223,7 @@ class VKParserWorker:
|
|||||||
VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
|
VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
""",
|
""",
|
||||||
JOB_TYPE_VK_STORAGE_COPY,
|
JOB_TYPE_MEDIA_STORAGE_COPY,
|
||||||
raw_post_id,
|
raw_post_id,
|
||||||
)
|
)
|
||||||
return int(raw_post_id)
|
return int(raw_post_id)
|
||||||
@@ -246,7 +246,9 @@ class VKParserWorker:
|
|||||||
recent = [item for item in fetched if item.posted_at > since_dt]
|
recent = [item for item in fetched if item.posted_at > since_dt]
|
||||||
known = await self.known_post_ids(source_id, [item.external_id for item in recent])
|
known = await self.known_post_ids(source_id, [item.external_id for item in recent])
|
||||||
candidates = [item for item in recent if item.external_id not in known]
|
candidates = [item for item in recent if item.external_id not in known]
|
||||||
min_text_length = max(0, await fetch_int_setting("parser_min_text_length", 0))
|
default_min_text_length = max(0, await fetch_int_setting("site_parser_min_text_length", 50))
|
||||||
|
source_config = json_object(source.get("settings_json"))
|
||||||
|
min_text_length = max(0, int(source_config.get("min_text_length", default_min_text_length)))
|
||||||
skip_empty_text = await fetch_bool_setting("parser_skip_empty_text", True)
|
skip_empty_text = await fetch_bool_setting("parser_skip_empty_text", True)
|
||||||
skip_no_media = await fetch_bool_setting("parser_skip_no_media", True)
|
skip_no_media = await fetch_bool_setting("parser_skip_no_media", True)
|
||||||
skip_short_text = await fetch_bool_setting("parser_skip_text_too_short", True)
|
skip_short_text = await fetch_bool_setting("parser_skip_text_too_short", True)
|
||||||
@@ -260,11 +262,11 @@ class VKParserWorker:
|
|||||||
if dedupe_content_hash and content_hash in known_hashes:
|
if dedupe_content_hash and content_hash in known_hashes:
|
||||||
continue
|
continue
|
||||||
skip_reason = None
|
skip_reason = None
|
||||||
if skip_empty_text and not item.text:
|
if skip_empty_text and not item.body_text:
|
||||||
skip_reason = "empty_text"
|
skip_reason = "empty_text"
|
||||||
elif skip_no_media and not item.media:
|
elif skip_no_media and not item.media:
|
||||||
skip_reason = "no_media"
|
skip_reason = "no_media"
|
||||||
elif skip_short_text and len(item.text) < min_text_length:
|
elif skip_short_text and len(item.body_text) < min_text_length:
|
||||||
skip_reason = "text_too_short"
|
skip_reason = "text_too_short"
|
||||||
if skip_reason and not store_skipped:
|
if skip_reason and not store_skipped:
|
||||||
continue
|
continue
|
||||||
@@ -389,7 +391,7 @@ class VKParserWorker:
|
|||||||
VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
|
VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
""",
|
""",
|
||||||
JOB_TYPE_VK_STORAGE_COPY,
|
JOB_TYPE_MEDIA_STORAGE_COPY,
|
||||||
raw_post_id,
|
raw_post_id,
|
||||||
)
|
)
|
||||||
return int(raw_post_id)
|
return int(raw_post_id)
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ class FollowedPageResponse(FakeResponse):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyBodyResponse(FakeResponse):
|
||||||
|
async def json(self, **_kwargs):
|
||||||
|
data = await super().json(**_kwargs)
|
||||||
|
data["items"][0]["text"] = ""
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
def post(self, *_args, **_kwargs):
|
def post(self, *_args, **_kwargs):
|
||||||
return FakeResponse()
|
return FakeResponse()
|
||||||
@@ -45,6 +52,11 @@ class FollowedPageSession(FakeSession):
|
|||||||
return FollowedPageResponse()
|
return FollowedPageResponse()
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyBodySession(FakeSession):
|
||||||
|
def post(self, *_args, **_kwargs):
|
||||||
|
return EmptyBodyResponse()
|
||||||
|
|
||||||
|
|
||||||
class SourceAdapterTests(unittest.IsolatedAsyncioTestCase):
|
class SourceAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_worker_response_is_normalized(self) -> None:
|
async def test_worker_response_is_normalized(self) -> None:
|
||||||
client = SiteParserClient(FakeSession(), "http://worker", "token", "captcha", 30)
|
client = SiteParserClient(FakeSession(), "http://worker", "token", "captcha", 30)
|
||||||
@@ -66,6 +78,17 @@ class SourceAdapterTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"runtime_state_json": "{}",
|
"runtime_state_json": "{}",
|
||||||
})
|
})
|
||||||
self.assertEqual(items[0].text, "Title\nAuthor")
|
self.assertEqual(items[0].text, "Title\nAuthor")
|
||||||
|
self.assertEqual(items[0].body_text, "Title\nAuthor")
|
||||||
|
|
||||||
|
async def test_title_is_not_counted_as_site_body(self) -> None:
|
||||||
|
client = SiteParserClient(EmptyBodySession(), "http://worker", "token", "captcha", 30)
|
||||||
|
items, _ = await client.fetch({
|
||||||
|
"url": "https://example.test/rss.xml",
|
||||||
|
"settings_json": '{"format":"rss"}',
|
||||||
|
"runtime_state_json": "{}",
|
||||||
|
})
|
||||||
|
self.assertEqual(items[0].text, "Title")
|
||||||
|
self.assertEqual(items[0].body_text, "")
|
||||||
|
|
||||||
def test_site_config_is_required(self) -> None:
|
def test_site_config_is_required(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "нужен конфиг"):
|
with self.assertRaisesRegex(ValueError, "нужен конфиг"):
|
||||||
@@ -75,6 +98,10 @@ class SourceAdapterTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "content_selector"):
|
with self.assertRaisesRegex(ValueError, "content_selector"):
|
||||||
validate_source_config("site", {"format": "rss", "follow_links": True})
|
validate_source_config("site", {"format": "rss", "follow_links": True})
|
||||||
|
|
||||||
|
def test_site_min_text_length_is_validated(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "min_text_length"):
|
||||||
|
validate_source_config("site", {"format": "rss", "min_text_length": "many"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from vk_parser_app.workers.vk_storage_uploader import TelegramStorageUploader, video_provider
|
from vk_parser_app.workers.media_uploader import MediaUploader, video_provider
|
||||||
|
|
||||||
|
|
||||||
class FakeContent:
|
class FakeContent:
|
||||||
@@ -35,7 +35,7 @@ def test_video_provider() -> None:
|
|||||||
|
|
||||||
async def test_http_video_download() -> None:
|
async def test_http_video_download() -> None:
|
||||||
path = "/tmp/vkparser_tg_media_http_test.mp4"
|
path = "/tmp/vkparser_tg_media_http_test.mp4"
|
||||||
result = await TelegramStorageUploader().download_http_video(FakeSession(), "https://example.test/a.mp4", path)
|
result = await MediaUploader().download_http_video(FakeSession(), "https://example.test/a.mp4", path)
|
||||||
assert result["size_bytes"] == 5
|
assert result["size_bytes"] == 5
|
||||||
assert Path(path).read_bytes() == b"video"
|
assert Path(path).read_bytes() == b"video"
|
||||||
Path(path).unlink()
|
Path(path).unlink()
|
||||||
|
|||||||
Reference in New Issue
Block a user