diff --git a/MULTI_PROJECT.md b/MULTI_PROJECT.md index 3ad01ba..6ba5143 100644 --- a/MULTI_PROJECT.md +++ b/MULTI_PROJECT.md @@ -31,6 +31,19 @@ Goal: keep one codebase and run separate deployments for each editorial project. Sources, target groups/channels, poster tokens, schedules, prompts, categories, and enabled workers should stay in `app_settings`, `sources`, `content_categories`, and `worker_controls`. +Common publication hashtags are also per-project database settings: + +- key: `publication_common_tags` +- FN-8: `#новости_снаряжения` +- RAA: `#raa_news #страйкбол` +- The app posts these tags before category/source tags in TG/VK/MAX social posters. + +RAA current social targets: + +- raw media Telegram channel: `tg_media_channel_id=-1004312015562` +- final Telegram publication channel: `tg_poster_chat_id=-1001303630155` +- VK owner: `vk_poster_owner_id=-36860851`, `vk_poster_from_group=true` + ## Poster modules The site poster is selected by `site_poster_provider`. A project can disable the worker or leave the provider empty when there is no website poster yet. New website integrations should be added as provider modules behind the same interface instead of branching the worker by project name. diff --git a/STATE.md b/STATE.md index 97d0f57..05ecb8e 100644 --- a/STATE.md +++ b/STATE.md @@ -48,6 +48,25 @@ Current runtime is local infrastructure, not the old VPS. - New installs default `site-poster` to disabled and must explicitly set provider, base URL, secret, and `site_poster_enabled=true`. +## RAA Duplicate + +As of 2026-08-03, the RAA duplicate is a separate Coolify app/database using the +same codebase shape. + +- Local copy path requested by user: `D:\DEVELOPMENT\raa-parser_poster`. +- Gitea repo: `http://192.168.1.135:3000/exostring/raa-parser_poster.git`. +- Coolify app UUID: `korokrhpoyqxf0nw2y8vk7lp` (`RAA admin`). +- Public admin URL: `https://raa.panel.f-n8.ru`. +- RAA Postgres container: `m9v14hzfh5mk9kelxzcqvhc6`, DB `raa_parser`, user `raa_parser_user`. +- Raw Telegram media channel setting: `tg_media_channel_id=-1004312015562` (`VK RAW RAA`). +- Final Telegram publication chat setting: `tg_poster_chat_id=-1001303630155` (`Red Airsoft | Страйкбол`). +- 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. +- Local Bot API app setting: `local_bot_api_url=http://127.0.0.1:8081`. +- As last checked, publication workers were intentionally disabled: + `vk-storage-uploader=false`, `tg-poster=false`, `vk-poster=false`, + `tg_poster_enabled=false`, `vk_poster_enabled=false`. + ## Important Text Rules - AI writer output text must be clean: no physical hashtags at the end. @@ -58,7 +77,9 @@ Current runtime is local infrastructure, not the old VPS. - Publication preview may show hashtags by composing: - clean text - blank line - - `#category #source` + - optional project-wide common tags first, then `#category #source` +- Project-wide common tags live in DB setting `publication_common_tags`. + FN-8 value: `#новости_снаряжения`. RAA value: `#raa_news #страйкбол`. - The editor textarea must show only clean editable text. - The list preview in `/editor` should show the publication preview, including composed hashtags. - `build_publication_text()` in `src/vk_parser_app/text_utils.py` is display/composition helper only. Do not use it before writing `final_text` to DB. diff --git a/db/migrations/041_publication_common_tags_and_category_descriptions.sql b/db/migrations/041_publication_common_tags_and_category_descriptions.sql new file mode 100644 index 0000000..27d88a2 --- /dev/null +++ b/db/migrations/041_publication_common_tags_and_category_descriptions.sql @@ -0,0 +1,13 @@ +ALTER TABLE content_categories + ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''; + +INSERT INTO app_settings(key, value_json, value_type, title, description, category) +VALUES ( + 'publication_common_tags', + '""'::jsonb, + 'text', + 'Общие хэштеги публикаций', + 'Один или несколько хэштегов, которые добавляются первыми в публикациях соцсетей. Можно писать через пробел, запятую или с новой строки.', + 'Publishing' +) +ON CONFLICT (key) DO NOTHING; diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index a310d08..bdee5dd 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -1325,13 +1325,21 @@ async def writer_category_payload() -> list[dict[str, Any]]: pool = await get_pool() rows = await pool.fetch( """ - SELECT sort_order AS id, name, tag + SELECT sort_order AS id, name, tag, COALESCE(description, '') AS description FROM content_categories WHERE is_active=TRUE ORDER BY sort_order, name """ ) - categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows] + categories = [ + { + "id": int(row["id"]), + "name": str(row["name"]), + "tag": str(row["tag"]), + "description": str(row["description"] or ""), + } + for row in rows + ] if categories: return categories return [ @@ -1344,7 +1352,8 @@ async def category_rows() -> list[dict[str, Any]]: pool = await get_pool() rows = await pool.fetch( """ - SELECT id, name, tag, site_name, site_slug, site_enabled, + SELECT id, name, tag, COALESCE(description, '') AS description, + site_name, site_slug, site_enabled, is_active, sort_order, created_at, updated_at FROM content_categories ORDER BY is_active DESC, sort_order, name @@ -3630,6 +3639,7 @@ async def category_create( csrf_token: str = Form(...), name: str = Form(...), tag: str = Form(""), + description: str = Form(""), site_name: str = Form(...), site_slug: str = Form(...), site_enabled: str | None = Form(None), @@ -3642,6 +3652,7 @@ async def category_create( if not name: return redirect("/workers") tag = normalize_hash_tag(tag or name, "category") + description = description.strip() site_name = site_name.strip() site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-") if not site_name or not site_slug: @@ -3650,24 +3661,26 @@ async def category_create( sort_order = int(await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM content_categories") or 1) await pool.execute( """ - INSERT INTO content_categories(name, tag, sort_order, site_name, site_slug, site_enabled) - VALUES($1, $2, $3, $4, $5, $6) + INSERT INTO content_categories(name, tag, sort_order, description, site_name, site_slug, site_enabled) + VALUES($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (name) DO UPDATE SET tag=$2, - site_name=$4, - site_slug=$5, - site_enabled=$6, + description=$4, + site_name=$5, + site_slug=$6, + site_enabled=$7, is_active=TRUE, updated_at=NOW() """, name, tag, sort_order, + description, site_name, site_slug, site_enabled is not None, ) - await audit(user["id"], "category.create", "content_category", None, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None}) + await audit(user["id"], "category.create", "content_category", None, {"name": name, "tag": tag, "description": description, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None}) return redirect("/workers") @@ -3678,6 +3691,7 @@ async def category_update( csrf_token: str = Form(...), name: str = Form(...), tag: str = Form(""), + description: str = Form(""), site_name: str = Form(...), site_slug: str = Form(...), site_enabled: str | None = Form(None), @@ -3690,6 +3704,7 @@ async def category_update( if not name: return redirect("/workers") tag = normalize_hash_tag(tag or name, "category") + description = description.strip() site_name = site_name.strip() site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-") if not site_name or not site_slug: @@ -3700,20 +3715,22 @@ async def category_update( UPDATE content_categories SET name=$2, tag=$3, - site_name=$4, - site_slug=$5, - site_enabled=$6, + description=$4, + site_name=$5, + site_slug=$6, + site_enabled=$7, updated_at=NOW() WHERE id=$1 """, category_id, name, tag, + description, site_name, site_slug, site_enabled is not None, ) - await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None}) + await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag, "description": description, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None}) return redirect("/workers") diff --git a/src/vk_parser_app/templates/workers.html b/src/vk_parser_app/templates/workers.html index 5520e1a..d42f86f 100644 --- a/src/vk_parser_app/templates/workers.html +++ b/src/vk_parser_app/templates/workers.html @@ -73,7 +73,7 @@
- Название описывает категорию для AI, тэг используется в соцсетях. + Название и описание помогают AI выбрать категорию, тэг используется в соцсетях.
@@ -88,6 +88,10 @@
+
+ + +
@@ -127,6 +131,7 @@
+
diff --git a/src/vk_parser_app/text_utils.py b/src/vk_parser_app/text_utils.py index a06128c..535ca55 100644 --- a/src/vk_parser_app/text_utils.py +++ b/src/vk_parser_app/text_utils.py @@ -23,8 +23,24 @@ def strip_trailing_hashtag_line(text: str) -> str: return "\n".join(lines).rstrip() -def publication_hashtags(category_tag: str, source_tag: str) -> str: +def parse_hash_tags(value: object) -> list[str]: + if isinstance(value, list): + raw_items = [str(item) for item in value] + else: + raw_items = re.split(r"[\s,|]+", str(value or "")) + tags: list[str] = [] + seen: set[str] = set() + for item in raw_items: + tag = normalize_hash_tag(item, "") + if tag and tag not in seen: + tags.append(tag) + seen.add(tag) + return tags + + +def publication_hashtags(category_tag: str, source_tag: str, common_tags: object = "") -> str: tags = [ + *parse_hash_tags(common_tags), normalize_hash_tag(category_tag, "category"), normalize_hash_tag(source_tag, "source"), ] @@ -38,6 +54,7 @@ def build_publication_text( text: str, category_tag: str, source_tag: str, + common_tags: object = "", format_title: bool = False, parse_mode: str | None = None, ) -> str: @@ -59,7 +76,7 @@ def build_publication_text( break if title_idx is None: - return publication_hashtags(category_tag, source_tag) + return publication_hashtags(category_tag, source_tag, common_tags) formatted_lines = [] for idx, line in enumerate(cleaned_lines): @@ -84,7 +101,7 @@ def build_publication_text( raw_body = "\n".join(formatted_lines) # Collapse multiple consecutive blank lines to at most two newlines (\n\n) normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip() - hashtags = publication_hashtags(category_tag, source_tag) + hashtags = publication_hashtags(category_tag, source_tag, common_tags) if hashtags: return f"{normalized_body}\n\n{hashtags}" if normalized_body else hashtags return normalized_body diff --git a/src/vk_parser_app/workers/ai_writer.py b/src/vk_parser_app/workers/ai_writer.py index 47f3de1..a5cf43d 100644 --- a/src/vk_parser_app/workers/ai_writer.py +++ b/src/vk_parser_app/workers/ai_writer.py @@ -16,7 +16,7 @@ from ..constants import WORKER_AI_WRITER from ..db import fetch_bool_setting, fetch_float_setting, fetch_int_setting, fetch_setting, get_pool from ..heartbeat import HeartbeatReporter from ..jobs import is_worker_enabled -from ..text_utils import build_publication_text, normalize_hash_tag, parse_categories +from ..text_utils import normalize_hash_tag, parse_categories, strip_trailing_hashtag_line from .ai_alerts import send_ai_worker_error_alert @@ -53,7 +53,7 @@ OUTPUT SCHEMA (return array matching input order): Rules: - Input is a JSON object with key "posts" containing accepted posts. -- Input has key "categories" with objects: id, name, tag. +- Input has key "categories" with objects: id, name, tag, description. - Each post includes producer_name and producer_tag. producer_name can be a manufacturer, shop, or publishing source. - The "text" field for every rewrite must explicitly mention producer_name at least once. Do not satisfy this only through producer_tag, hashtags, notes, or metadata. - Pick exactly one category from the categories list in input and return its numeric id as category_id. @@ -208,7 +208,7 @@ async def load_writer_categories(pool) -> list[dict[str, Any]]: try: rows = await pool.fetch( """ - SELECT sort_order AS id, name, tag + SELECT sort_order AS id, name, tag, COALESCE(description, '') AS description FROM content_categories WHERE is_active=TRUE ORDER BY sort_order, name @@ -216,7 +216,15 @@ async def load_writer_categories(pool) -> list[dict[str, Any]]: ) except Exception: rows = [] - categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows] + categories = [ + { + "id": int(row["id"]), + "name": str(row["name"]), + "tag": str(row["tag"]), + "description": str(row["description"] or ""), + } + for row in rows + ] if categories: return categories legacy = parse_categories(await fetch_setting("ai_writer_categories", [])) @@ -399,7 +407,7 @@ class AIWriterWorker: async def apply_rewrites(self, batch_id: int, rewrites: list[dict], model: str, prompt: str) -> None: for item in rewrites: - final_text = build_publication_text(item["text"], item["category_tag"], item["source_tag"]) + final_text = strip_trailing_hashtag_line(item["text"]) editorial_status = "rejected" if item.get("reject_by_category") else "review" editor_notes = "Отклонено AI: Не целевой контент" if item.get("reject_by_category") else None await self.pool.execute( diff --git a/src/vk_parser_app/workers/max_poster.py b/src/vk_parser_app/workers/max_poster.py index 8882944..6661930 100644 --- a/src/vk_parser_app/workers/max_poster.py +++ b/src/vk_parser_app/workers/max_poster.py @@ -218,6 +218,7 @@ class MAXPoster: self.auto_reaction = "👍" self.reaction_path_template = "/messages/{message_id}/reactions" self.dry_run = False + self.common_tags = "" async def init(self) -> None: self.pool = await get_pool() @@ -243,6 +244,7 @@ class MAXPoster: self.auto_reaction = str(await fetch_setting("max_poster_auto_reaction", "👍") or "👍").strip() or "👍" self.reaction_path_template = str(await fetch_setting("max_poster_reaction_path_template", self.reaction_path_template) or "").strip() self.dry_run = await fetch_bool_setting("max_poster_dry_run", False) + self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip() if not self.token: raise RuntimeError("max_poster_bot_token is empty") if not self.chat_id: @@ -371,7 +373,12 @@ class MAXPoster: def build_text(self, post: dict[str, Any]) -> str: category_tag = str(post.get("final_category_tag") or post.get("rewrite_category_tag") or post.get("final_category") or post.get("rewrite_category") or "") source_tag = str(post.get("final_source_tag") or post.get("rewrite_source_tag") or post.get("source_tag") or "") - return build_publication_text(post.get("final_text") or post.get("rewritten_text") or "", category_tag, source_tag) + return build_publication_text( + post.get("final_text") or post.get("rewritten_text") or "", + category_tag, + source_tag, + self.common_tags, + ) async def download_media_to_temp(self, session: aiohttp.ClientSession, item: dict[str, Any]) -> Path | None: media_type = str(item.get("media_type") or "") diff --git a/src/vk_parser_app/workers/tg_poster.py b/src/vk_parser_app/workers/tg_poster.py index 985c091..d83ae27 100644 --- a/src/vk_parser_app/workers/tg_poster.py +++ b/src/vk_parser_app/workers/tg_poster.py @@ -111,6 +111,7 @@ class TelegramPoster: self.recent_window = 20 self.category_repeat_penalty = 3.0 self.source_repeat_penalty = 4.0 + self.common_tags = "" async def init(self) -> None: self.pool = await get_pool() @@ -137,6 +138,7 @@ class TelegramPoster: self.recent_window = max(1, await fetch_int_setting("tg_poster_recent_window", 20)) self.category_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_category_repeat_penalty", 3.0)) self.source_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_source_repeat_penalty", 4.0)) + self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip() logger.info("TG poster config: chat={} schedule={} caption_limit={}", self.chat_id, self.schedule, self.caption_limit) async def close(self) -> None: @@ -325,7 +327,14 @@ class TelegramPoster: def build_text(self, post: dict[str, Any]) -> str: category_tag = str(post.get("final_category_tag") or post.get("rewrite_category_tag") or post.get("final_category") or post.get("rewrite_category") or "") source_tag = str(post.get("final_source_tag") or post.get("rewrite_source_tag") or post.get("source_tag") or "") - return build_publication_text(post.get("final_text") or post.get("rewritten_text") or "", category_tag, source_tag, format_title=True, parse_mode="html") + return build_publication_text( + post.get("final_text") or post.get("rewritten_text") or "", + category_tag, + source_tag, + self.common_tags, + format_title=True, + parse_mode="html", + ) async def send_text(self, text: str) -> list[int]: message_ids: list[int] = [] diff --git a/src/vk_parser_app/workers/vk_poster.py b/src/vk_parser_app/workers/vk_poster.py index 9ffa960..6b5fa92 100644 --- a/src/vk_parser_app/workers/vk_poster.py +++ b/src/vk_parser_app/workers/vk_poster.py @@ -72,6 +72,7 @@ class VKPoster: self.category_repeat_penalty = 3.0 self.source_repeat_penalty = 4.0 self.dry_run = False + self.common_tags = "" async def init(self) -> None: self.pool = await get_pool() @@ -158,6 +159,7 @@ class VKPoster: self.category_repeat_penalty = max(0.0, await fetch_float_setting("vk_poster_category_repeat_penalty", 3.0)) self.source_repeat_penalty = max(0.0, await fetch_float_setting("vk_poster_source_repeat_penalty", 4.0)) self.dry_run = await fetch_bool_setting("vk_poster_dry_run", False) + self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip() if not self.token: raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty") if not self.owner_id: @@ -292,7 +294,12 @@ class VKPoster: def build_text(self, post: dict[str, Any]) -> str: category_tag = str(post.get("final_category_tag") or post.get("rewrite_category_tag") or post.get("final_category") or post.get("rewrite_category") or "") source_tag = str(post.get("final_source_tag") or post.get("rewrite_source_tag") or post.get("source_tag") or "") - return build_publication_text(post.get("final_text") or post.get("rewritten_text") or "", category_tag, source_tag)[:VK_MESSAGE_LIMIT] + return build_publication_text( + post.get("final_text") or post.get("rewritten_text") or "", + category_tag, + source_tag, + self.common_tags, + )[:VK_MESSAGE_LIMIT] async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None: attachment_id = str(item.get("original_attachment_id") or "").strip()