Add project branding and poster footers
This commit is contained in:
@@ -2,6 +2,7 @@ APP_ENV=production
|
|||||||
APP_SECRET_KEY=change-me
|
APP_SECRET_KEY=change-me
|
||||||
ADMIN_SITE_TITLE=Редакторская
|
ADMIN_SITE_TITLE=Редакторская
|
||||||
ADMIN_APP_TITLE=Редакторская
|
ADMIN_APP_TITLE=Редакторская
|
||||||
|
# Optional fallback favicon path; uploaded per-project favicon takes precedence
|
||||||
ADMIN_FAVICON_PATH=
|
ADMIN_FAVICON_PATH=
|
||||||
ADMIN_BOOTSTRAP_LOGIN=admin
|
ADMIN_BOOTSTRAP_LOGIN=admin
|
||||||
ADMIN_BOOTSTRAP_PASSWORD=change-me-long-password
|
ADMIN_BOOTSTRAP_PASSWORD=change-me-long-password
|
||||||
|
|||||||
+5
-1
@@ -17,7 +17,7 @@ Goal: keep one codebase and run separate deployments for each editorial project.
|
|||||||
|
|
||||||
- `ADMIN_SITE_TITLE`
|
- `ADMIN_SITE_TITLE`
|
||||||
- `ADMIN_APP_TITLE`
|
- `ADMIN_APP_TITLE`
|
||||||
- `ADMIN_FAVICON_PATH`
|
- `ADMIN_FAVICON_PATH` as an optional fallback only
|
||||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||||
- `APP_SECRET_KEY`
|
- `APP_SECRET_KEY`
|
||||||
- `ADMIN_BOOTSTRAP_LOGIN`, `ADMIN_BOOTSTRAP_PASSWORD`
|
- `ADMIN_BOOTSTRAP_LOGIN`, `ADMIN_BOOTSTRAP_PASSWORD`
|
||||||
@@ -35,6 +35,10 @@ 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`.
|
Sources, target groups/channels, poster tokens, schedules, prompts, categories, and enabled workers should stay in `app_settings`, `sources`, `content_categories`, and `worker_controls`.
|
||||||
|
|
||||||
|
Project favicon is uploaded from the admin UI and stored per deployment at
|
||||||
|
`uploads/branding/favicon.png`. If it is absent, the app falls back to
|
||||||
|
`ADMIN_FAVICON_PATH`, then to the repository `favi.png`.
|
||||||
|
|
||||||
Common publication hashtags are also per-project database settings:
|
Common publication hashtags are also per-project database settings:
|
||||||
|
|
||||||
- key: `publication_common_tags`
|
- key: `publication_common_tags`
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'vk_poster_footer_text',
|
||||||
|
'""'::jsonb,
|
||||||
|
'text',
|
||||||
|
'VK текст перед хэштегами',
|
||||||
|
'Текст, который добавляется после поста и перед хэштегами только для VK. Можно использовать VK-разметку ссылок: [https://example.com|Текст].',
|
||||||
|
'VK Poster'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'tg_poster_footer_text',
|
||||||
|
'""'::jsonb,
|
||||||
|
'text',
|
||||||
|
'TG текст перед хэштегами',
|
||||||
|
'Текст, который добавляется после поста и перед хэштегами только для Telegram. Для ссылок используй HTML: <a href="https://example.com">Текст</a>.',
|
||||||
|
'TG Poster'
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -43,17 +43,34 @@ VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
|
|||||||
VK_OAUTH_STATE_COOKIE = "vk_oauth_state"
|
VK_OAUTH_STATE_COOKIE = "vk_oauth_state"
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
PROJECT_ROOT = BASE_DIR.parents[1]
|
PROJECT_ROOT = BASE_DIR.parents[1]
|
||||||
FAVICON_PATH = Path(settings.admin_favicon_path).resolve() if settings.admin_favicon_path.strip() else PROJECT_ROOT / "favi.png"
|
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
app = FastAPI(title=settings.display_site_title)
|
app = FastAPI(title=settings.display_site_title)
|
||||||
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
|
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
|
||||||
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
|
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
|
||||||
UPLOAD_ROOT = Path("uploads").resolve()
|
UPLOAD_ROOT = Path("uploads").resolve()
|
||||||
|
BRANDING_DIR = UPLOAD_ROOT / "branding"
|
||||||
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
|
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
|
||||||
|
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads")
|
||||||
|
|
||||||
|
|
||||||
|
def favicon_path() -> Path:
|
||||||
|
uploaded = BRANDING_DIR / "favicon.png"
|
||||||
|
if uploaded.is_file():
|
||||||
|
return uploaded
|
||||||
|
if settings.admin_favicon_path.strip():
|
||||||
|
return Path(settings.admin_favicon_path).resolve()
|
||||||
|
return PROJECT_ROOT / "favi.png"
|
||||||
|
|
||||||
|
|
||||||
|
def favicon_version() -> int:
|
||||||
|
try:
|
||||||
|
return int(favicon_path().stat().st_mtime)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def public_origin(request: Request) -> str:
|
def public_origin(request: Request) -> str:
|
||||||
configured = settings.vk_oauth_origin.strip().rstrip("/")
|
configured = settings.vk_oauth_origin.strip().rstrip("/")
|
||||||
if configured:
|
if configured:
|
||||||
@@ -72,12 +89,12 @@ def oauth_redirect_uri(request: Request, path: str) -> str:
|
|||||||
|
|
||||||
@app.get("/favi.png")
|
@app.get("/favi.png")
|
||||||
async def favicon_png():
|
async def favicon_png():
|
||||||
return FileResponse(FAVICON_PATH)
|
return FileResponse(favicon_path())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.ico")
|
@app.get("/favicon.ico")
|
||||||
async def favicon_ico():
|
async def favicon_ico():
|
||||||
return FileResponse(FAVICON_PATH, media_type="image/png")
|
return FileResponse(favicon_path(), media_type="image/png")
|
||||||
|
|
||||||
PROVIDER_OPTIONS = [
|
PROVIDER_OPTIONS = [
|
||||||
{"value": "openrouter", "label": "OpenRouter"},
|
{"value": "openrouter", "label": "OpenRouter"},
|
||||||
@@ -176,6 +193,7 @@ CATEGORY_TITLES = {
|
|||||||
"TG Reactor": "TG-реактор",
|
"TG Reactor": "TG-реактор",
|
||||||
"VK Poster": "VK-постер",
|
"VK Poster": "VK-постер",
|
||||||
"MAX Poster": "MAX-постер",
|
"MAX Poster": "MAX-постер",
|
||||||
|
"Publishing": "Публикации",
|
||||||
"Daily Report": "Ежедневный отчет",
|
"Daily Report": "Ежедневный отчет",
|
||||||
"Parser": "Парсер",
|
"Parser": "Парсер",
|
||||||
"Uploader": "Аплоадер",
|
"Uploader": "Аплоадер",
|
||||||
@@ -191,6 +209,7 @@ CATEGORY_ORDER = {
|
|||||||
"VK Poster": 50,
|
"VK Poster": 50,
|
||||||
"MAX Poster": 51,
|
"MAX Poster": 51,
|
||||||
"Site Poster": 52,
|
"Site Poster": 52,
|
||||||
|
"Publishing": 53,
|
||||||
"Daily Report": 55,
|
"Daily Report": 55,
|
||||||
"Parser": 60,
|
"Parser": 60,
|
||||||
"VK": 70,
|
"VK": 70,
|
||||||
@@ -252,6 +271,7 @@ SETTING_ORDER = {
|
|||||||
"tg_poster_recent_window",
|
"tg_poster_recent_window",
|
||||||
"tg_poster_category_repeat_penalty",
|
"tg_poster_category_repeat_penalty",
|
||||||
"tg_poster_source_repeat_penalty",
|
"tg_poster_source_repeat_penalty",
|
||||||
|
"tg_poster_footer_text",
|
||||||
],
|
],
|
||||||
"TG Reactor": [
|
"TG Reactor": [
|
||||||
"tg_reactor_bot_tokens",
|
"tg_reactor_bot_tokens",
|
||||||
@@ -282,6 +302,10 @@ SETTING_ORDER = {
|
|||||||
"vk_poster_category_repeat_penalty",
|
"vk_poster_category_repeat_penalty",
|
||||||
"vk_poster_source_repeat_penalty",
|
"vk_poster_source_repeat_penalty",
|
||||||
"vk_poster_dry_run",
|
"vk_poster_dry_run",
|
||||||
|
"vk_poster_footer_text",
|
||||||
|
],
|
||||||
|
"Publishing": [
|
||||||
|
"publication_common_tags",
|
||||||
],
|
],
|
||||||
"MAX Poster": [
|
"MAX Poster": [
|
||||||
"max_poster_bot_token",
|
"max_poster_bot_token",
|
||||||
@@ -475,6 +499,7 @@ def base_context(request: Request, user: dict | None, **extra: Any) -> dict[str,
|
|||||||
"app_env": settings.app_env,
|
"app_env": settings.app_env,
|
||||||
"app_title": settings.display_admin_title,
|
"app_title": settings.display_admin_title,
|
||||||
"site_title": settings.display_site_title,
|
"site_title": settings.display_site_title,
|
||||||
|
"favicon_version": favicon_version(),
|
||||||
"site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS,
|
"site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS,
|
||||||
}
|
}
|
||||||
ctx.update(extra)
|
ctx.update(extra)
|
||||||
@@ -3888,6 +3913,20 @@ async def settings_save(request: Request, csrf_token: str = Form(...), key: str
|
|||||||
return redirect("/workers")
|
return redirect("/workers")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/branding/favicon")
|
||||||
|
async def branding_favicon_save(request: Request, csrf_token: str = Form(...), favicon_file: UploadFile = File(...)):
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return redirect("/login")
|
||||||
|
require_csrf(user, csrf_token)
|
||||||
|
content = await favicon_file.read()
|
||||||
|
if not content.startswith(b"\x89PNG\r\n\x1a\n") or len(content) > 1024 * 1024:
|
||||||
|
return redirect("/workers")
|
||||||
|
(BRANDING_DIR / "favicon.png").write_bytes(content)
|
||||||
|
await audit(user["id"], "branding.favicon_update", "branding", None, {"filename": favicon_file.filename})
|
||||||
|
return redirect("/workers")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logs", response_class=HTMLResponse)
|
@app.get("/logs", response_class=HTMLResponse)
|
||||||
async def logs_page(
|
async def logs_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/png" href="/favi.png">
|
<link rel="icon" type="image/png" href="/favi.png?v={{ favicon_version or 0 }}">
|
||||||
<title>{{ title or site_title or "Редакторская" }}</title>
|
<title>{{ title or site_title or "Редакторская" }}</title>
|
||||||
|
|
||||||
<!-- Fonts: Fira Sans (UI) & Fira Code (Data) -->
|
<!-- Fonts: Fira Sans (UI) & Fira Code (Data) -->
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
<i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать
|
<i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif p.editorial_status in ['accepted', 'publish_failed'] %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" name="action" value="accept" class="btn btn-primary btn-sm">
|
||||||
|
<i data-lucide="save" class="w-4 h-4"></i> Сохранить правки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<details class="card group/details" open>
|
<details class="card group/details" data-workers-details="categories" open>
|
||||||
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
||||||
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
||||||
<i data-lucide="tags" class="w-5 h-5 text-app-primary"></i> Категории публикаций
|
<i data-lucide="tags" class="w-5 h-5 text-app-primary"></i> Категории публикаций
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
<!-- TG Schedule -->
|
<!-- TG Schedule -->
|
||||||
<details class="card group/details" open>
|
<details class="card group/details" data-workers-details="tg-schedule" open>
|
||||||
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
||||||
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
||||||
<i data-lucide="send" class="w-5 h-5 text-[#0088cc]"></i> Расписание TG-постера
|
<i data-lucide="send" class="w-5 h-5 text-[#0088cc]"></i> Расписание TG-постера
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- VK Schedule -->
|
<!-- VK Schedule -->
|
||||||
<details class="card group/details" open>
|
<details class="card group/details" data-workers-details="vk-schedule" open>
|
||||||
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
||||||
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
||||||
<i data-lucide="layout-template" class="w-5 h-5 text-[#4680C2]"></i> Расписание VK-постера
|
<i data-lucide="layout-template" class="w-5 h-5 text-[#4680C2]"></i> Расписание VK-постера
|
||||||
@@ -259,6 +259,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="card group/details mb-8" data-workers-details="branding">
|
||||||
|
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
||||||
|
<div class="flex items-center gap-2 font-bold text-lg text-white">
|
||||||
|
<i data-lucide="image" class="w-5 h-5 text-app-primary"></i> Брендинг проекта
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
|
||||||
|
</summary>
|
||||||
|
<div class="p-4 bg-app-bg/30">
|
||||||
|
<form method="post" action="/branding/favicon" enctype="multipart/form-data" class="flex flex-col md:flex-row md:items-end gap-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||||
|
<div class="w-16 h-16 rounded-lg bg-app-bg border border-app-border flex items-center justify-center overflow-hidden">
|
||||||
|
<img src="/favi.png?v={{ favicon_version or 0 }}" alt="" class="w-full h-full object-contain">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Фавикон проекта</label>
|
||||||
|
<input type="file" name="favicon_file" accept="image/png" class="block w-full text-xs text-app-textMuted file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-app-border file:text-white hover:file:bg-app-borderFocus cursor-pointer border border-app-border bg-app-bg rounded-md" required>
|
||||||
|
<div class="text-xs text-app-textMuted mt-2">PNG до 1 МБ. Хранится отдельно для этого проекта в uploads/branding/favicon.png.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">
|
||||||
|
<i data-lucide="upload" class="w-4 h-4"></i> Загрузить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mb-6 mt-12">
|
<div class="flex items-center gap-4 mb-6 mt-12">
|
||||||
<div class="h-px bg-app-border flex-1"></div>
|
<div class="h-px bg-app-border flex-1"></div>
|
||||||
<h2 class="text-2xl font-bold text-white flex items-center gap-3">
|
<h2 class="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
@@ -269,7 +294,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
{% for category, rows in settings|groupby("category") %}
|
{% for category, rows in settings|groupby("category") %}
|
||||||
<details class="card group/details" {% if category == "AI Qualifier" or loop.first %}open{% endif %}>
|
<details class="card group/details" data-workers-details="settings:{{ category }}">
|
||||||
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
|
||||||
<div class="text-lg font-bold text-white">{{ category_titles.get(category, category) }}</div>
|
<div class="text-lg font-bold text-white">{{ category_titles.get(category, category) }}</div>
|
||||||
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
|
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
|
||||||
@@ -377,16 +402,34 @@
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const scrollKey = "workers-scroll-y";
|
const scrollKey = "workers-scroll-y";
|
||||||
|
const detailsKey = "workers-open-details";
|
||||||
|
const detailItems = Array.from(document.querySelectorAll("details[data-workers-details]"));
|
||||||
|
const savedDetails = sessionStorage.getItem(detailsKey);
|
||||||
|
if (savedDetails) {
|
||||||
|
try {
|
||||||
|
const openIds = new Set(JSON.parse(savedDetails));
|
||||||
|
detailItems.forEach((details) => {
|
||||||
|
details.open = openIds.has(details.dataset.workersDetails);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(detailsKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
const restoreY = sessionStorage.getItem(scrollKey);
|
const restoreY = sessionStorage.getItem(scrollKey);
|
||||||
if (restoreY !== null) {
|
if (restoreY !== null) {
|
||||||
sessionStorage.removeItem(scrollKey);
|
sessionStorage.removeItem(scrollKey);
|
||||||
requestAnimationFrame(() => window.scrollTo(0, Number(restoreY) || 0));
|
requestAnimationFrame(() => window.scrollTo(0, Number(restoreY) || 0));
|
||||||
}
|
}
|
||||||
|
const saveDetails = () => {
|
||||||
|
sessionStorage.setItem(detailsKey, JSON.stringify(detailItems.filter((details) => details.open).map((details) => details.dataset.workersDetails)));
|
||||||
|
};
|
||||||
|
detailItems.forEach((details) => details.addEventListener("toggle", saveDetails));
|
||||||
document.addEventListener("submit", (event) => {
|
document.addEventListener("submit", (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
if (!(form instanceof HTMLFormElement)) return;
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
const action = form.getAttribute("action") || "";
|
const action = form.getAttribute("action") || "";
|
||||||
if (action.startsWith("/workers") || action.startsWith("/settings") || action.includes("-schedule/")) {
|
if (action.startsWith("/workers") || action.startsWith("/settings") || action.startsWith("/branding") || action.includes("-schedule/")) {
|
||||||
|
saveDetails();
|
||||||
sessionStorage.setItem(scrollKey, String(window.scrollY));
|
sessionStorage.setItem(scrollKey, String(window.scrollY));
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def build_publication_text(
|
|||||||
category_tag: str,
|
category_tag: str,
|
||||||
source_tag: str,
|
source_tag: str,
|
||||||
common_tags: object = "",
|
common_tags: object = "",
|
||||||
|
footer_text: str = "",
|
||||||
format_title: bool = False,
|
format_title: bool = False,
|
||||||
parse_mode: str | None = None,
|
parse_mode: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -75,8 +76,10 @@ def build_publication_text(
|
|||||||
title_idx = idx
|
title_idx = idx
|
||||||
break
|
break
|
||||||
|
|
||||||
|
footer = str(footer_text or "").strip()
|
||||||
|
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
|
||||||
if title_idx is None:
|
if title_idx is None:
|
||||||
return publication_hashtags(category_tag, source_tag, common_tags)
|
return "\n\n".join(part for part in [footer, hashtags] if part)
|
||||||
|
|
||||||
formatted_lines = []
|
formatted_lines = []
|
||||||
for idx, line in enumerate(cleaned_lines):
|
for idx, line in enumerate(cleaned_lines):
|
||||||
@@ -101,10 +104,8 @@ def build_publication_text(
|
|||||||
raw_body = "\n".join(formatted_lines)
|
raw_body = "\n".join(formatted_lines)
|
||||||
# Collapse multiple consecutive blank lines to at most two newlines (\n\n)
|
# Collapse multiple consecutive blank lines to at most two newlines (\n\n)
|
||||||
normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip()
|
normalized_body = re.sub(r"\n{3,}", "\n\n", raw_body).strip()
|
||||||
hashtags = publication_hashtags(category_tag, source_tag, common_tags)
|
parts = [part for part in [normalized_body, footer, hashtags] if part]
|
||||||
if hashtags:
|
return "\n\n".join(parts)
|
||||||
return f"{normalized_body}\n\n{hashtags}" if normalized_body else hashtags
|
|
||||||
return normalized_body
|
|
||||||
|
|
||||||
|
|
||||||
def parse_categories(value: object) -> list[str]:
|
def parse_categories(value: object) -> list[str]:
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class TelegramPoster:
|
|||||||
self.category_repeat_penalty = 3.0
|
self.category_repeat_penalty = 3.0
|
||||||
self.source_repeat_penalty = 4.0
|
self.source_repeat_penalty = 4.0
|
||||||
self.common_tags = ""
|
self.common_tags = ""
|
||||||
|
self.footer_text = ""
|
||||||
|
|
||||||
async def init(self) -> None:
|
async def init(self) -> None:
|
||||||
self.pool = await get_pool()
|
self.pool = await get_pool()
|
||||||
@@ -139,6 +140,7 @@ class TelegramPoster:
|
|||||||
self.category_repeat_penalty = max(0.0, await fetch_float_setting("tg_poster_category_repeat_penalty", 3.0))
|
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.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()
|
self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip()
|
||||||
|
self.footer_text = str(await fetch_setting("tg_poster_footer_text", "") or "").strip()
|
||||||
logger.info("TG poster config: chat={} schedule={} caption_limit={}", self.chat_id, self.schedule, self.caption_limit)
|
logger.info("TG poster config: chat={} schedule={} caption_limit={}", self.chat_id, self.schedule, self.caption_limit)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -332,6 +334,7 @@ class TelegramPoster:
|
|||||||
category_tag,
|
category_tag,
|
||||||
source_tag,
|
source_tag,
|
||||||
self.common_tags,
|
self.common_tags,
|
||||||
|
self.footer_text,
|
||||||
format_title=True,
|
format_title=True,
|
||||||
parse_mode="html",
|
parse_mode="html",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class VKPoster:
|
|||||||
self.source_repeat_penalty = 4.0
|
self.source_repeat_penalty = 4.0
|
||||||
self.dry_run = False
|
self.dry_run = False
|
||||||
self.common_tags = ""
|
self.common_tags = ""
|
||||||
|
self.footer_text = ""
|
||||||
|
|
||||||
async def init(self) -> None:
|
async def init(self) -> None:
|
||||||
self.pool = await get_pool()
|
self.pool = await get_pool()
|
||||||
@@ -160,6 +161,7 @@ class VKPoster:
|
|||||||
self.source_repeat_penalty = max(0.0, await fetch_float_setting("vk_poster_source_repeat_penalty", 4.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.dry_run = await fetch_bool_setting("vk_poster_dry_run", False)
|
||||||
self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip()
|
self.common_tags = str(await fetch_setting("publication_common_tags", "") or "").strip()
|
||||||
|
self.footer_text = str(await fetch_setting("vk_poster_footer_text", "") or "").strip()
|
||||||
if not self.token:
|
if not self.token:
|
||||||
raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty")
|
raise RuntimeError("vk_poster_access_token, VK_GROUP_ACCESS_TOKEN and VK_ACCESS_TOKEN are empty")
|
||||||
if not self.owner_id:
|
if not self.owner_id:
|
||||||
@@ -299,6 +301,7 @@ class VKPoster:
|
|||||||
category_tag,
|
category_tag,
|
||||||
source_tag,
|
source_tag,
|
||||||
self.common_tags,
|
self.common_tags,
|
||||||
|
self.footer_text,
|
||||||
)[:VK_MESSAGE_LIMIT]
|
)[:VK_MESSAGE_LIMIT]
|
||||||
|
|
||||||
async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None:
|
async def media_attachment(self, client: VKAPIClient, item: dict[str, Any]) -> str | None:
|
||||||
|
|||||||
Reference in New Issue
Block a user