diff --git a/.env.example b/.env.example index 88f9c59..dc55b81 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ DB_PASSWORD=change-me VK_ACCESS_TOKEN= VK_GROUP_ACCESS_TOKEN= VK_API_VERSION=5.199 -VK_STORAGE_GROUP_ID=239548476 +VK_STORAGE_GROUP_ID=0 TG_BOT_TOKEN= TG_MEDIA_CHANNEL_ID= diff --git a/db/migrations/024_tg_poster.sql b/db/migrations/024_tg_poster.sql index e8145ea..965507f 100644 --- a/db/migrations/024_tg_poster.sql +++ b/db/migrations/024_tg_poster.sql @@ -39,7 +39,7 @@ INSERT INTO app_settings(key, value_json, value_type, title, description, catego VALUES ('tg_poster_enabled', 'false'::jsonb, 'bool', 'TG poster enabled', 'Double safety flag for publishing accepted posts to Telegram.', 'TG Poster'), ('tg_poster_bot_token', '""'::jsonb, 'secret', 'TG poster bot token', 'Bot token for final Telegram publication. For test can be the same as upload bot token.', 'TG Poster'), - ('tg_poster_chat_id', '"-1003712724327"'::jsonb, 'str', 'TG publication chat id', 'Target Telegram channel/group id. Supports chat_id:thread_id for topics.', 'TG Poster'), + ('tg_poster_chat_id', '""'::jsonb, 'str', 'TG publication chat id', 'Target Telegram channel/group id. Supports chat_id:thread_id for topics.', 'TG Poster'), ('tg_poster_schedule_json', '[{"id":"morning","time":"10:00","count":1,"enabled":true},{"id":"evening","time":"18:00","count":1,"enabled":true}]'::jsonb, 'json', 'TG publication schedule', 'EKB time schedule rows: id, time HH:MM, count, enabled.', 'TG Poster'), ('tg_poster_interval_sec', '60'::jsonb, 'int', 'TG poster interval, sec', 'Pause between poster checks.', 'TG Poster'), ('tg_poster_media_group_max_items', '10'::jsonb, 'int', 'TG media group max items', 'Telegram media group supports 2..10 media items.', 'TG Poster'), diff --git a/db/migrations/038_site_poster.sql b/db/migrations/038_site_poster.sql index 3c761cb..3edd067 100644 --- a/db/migrations/038_site_poster.sql +++ b/db/migrations/038_site_poster.sql @@ -1,13 +1,15 @@ INSERT INTO worker_controls(name, enabled, settings_json) -VALUES ('site-poster', TRUE, '{}'::jsonb) +VALUES ('site-poster', FALSE, '{}'::jsonb) ON CONFLICT (name) DO NOTHING; INSERT INTO app_settings(key, value_json, value_type, title, description, category) VALUES - ('site_poster_enabled', 'true'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic Ghost publishing.', 'Site Poster'), + ('site_poster_enabled', 'false'::jsonb, 'bool', 'Site poster enabled', 'Double safety flag for automatic site publishing.', 'Site Poster'), + ('site_poster_provider', '""'::jsonb, 'str', 'Site provider', 'Publish adapter: ghost now; wordpress can be added as a code adapter later.', 'Site Poster'), + ('site_poster_base_url', '""'::jsonb, 'str', 'Site API base URL', 'Public/admin base URL for the selected site provider.', 'Site Poster'), + ('site_poster_secret_file', '""'::jsonb, 'str', 'Site secret file', 'Server path to the selected provider API secret. Keep empty in git.', 'Site Poster'), + ('site_poster_fallback_title', '"Новость"'::jsonb, 'str', 'Fallback article title', 'Title used when post text has no first line.', 'Site Poster'), ('site_poster_interval_sec', '300'::jsonb, 'int', 'Site poster interval, sec', 'Pause between automatic queue checks.', 'Site Poster'), ('site_poster_photo_batch_size', '10'::jsonb, 'int', 'Photo batch size', 'Maximum photo-ready posts per automatic run.', 'Site Poster'), - ('site_poster_video_batch_size', '1'::jsonb, 'int', 'Video batch size', 'Maximum video-only posts per automatic run.', 'Site Poster'), - ('site_poster_ghost_url', '"https://f-n8.ru"'::jsonb, 'str', 'Ghost URL', 'Target Ghost public URL.', 'Site Poster'), - ('site_poster_key_file', '"/opt/vk-parser/.site-poster-key"'::jsonb, 'str', 'Ghost key file', 'Server path to the Ghost Admin API key.', 'Site Poster') + ('site_poster_video_batch_size', '1'::jsonb, 'int', 'Video batch size', 'Maximum video-only posts per automatic run.', 'Site Poster') ON CONFLICT (key) DO NOTHING; diff --git a/db/migrations/040_site_poster_provider_settings.sql b/db/migrations/040_site_poster_provider_settings.sql new file mode 100644 index 0000000..9972bd7 --- /dev/null +++ b/db/migrations/040_site_poster_provider_settings.sql @@ -0,0 +1,36 @@ +INSERT INTO app_settings(key, value_json, value_type, title, description, category) +VALUES + ('site_poster_provider', '""'::jsonb, 'str', 'Site provider', 'Publish adapter: ghost now; wordpress can be added as a code adapter later.', 'Site Poster'), + ('site_poster_base_url', '""'::jsonb, 'str', 'Site API base URL', 'Public/admin base URL for the selected site provider.', 'Site Poster'), + ('site_poster_secret_file', '""'::jsonb, 'str', 'Site secret file', 'Server path to the selected provider API secret. Keep empty in git.', 'Site Poster'), + ('site_poster_fallback_title', '"Новость"'::jsonb, 'str', 'Fallback article title', 'Title used when post text has no first line.', 'Site Poster') +ON CONFLICT (key) DO NOTHING; + +UPDATE app_settings +SET value_json='"ghost"'::jsonb, + updated_at=NOW() +WHERE key='site_poster_provider' + AND COALESCE(value_json #>> '{}', '') = '' + AND EXISTS ( + SELECT 1 FROM app_settings legacy + WHERE legacy.key='site_poster_ghost_url' + AND COALESCE(legacy.value_json #>> '{}', '') <> '' + ); + +UPDATE app_settings target +SET value_json=to_jsonb(legacy.value_json #>> '{}'), + updated_at=NOW() +FROM app_settings legacy +WHERE target.key='site_poster_base_url' + AND legacy.key='site_poster_ghost_url' + AND COALESCE(target.value_json #>> '{}', '') = '' + AND COALESCE(legacy.value_json #>> '{}', '') <> ''; + +UPDATE app_settings target +SET value_json=to_jsonb(legacy.value_json #>> '{}'), + updated_at=NOW() +FROM app_settings legacy +WHERE target.key='site_poster_secret_file' + AND legacy.key='site_poster_key_file' + AND COALESCE(target.value_json #>> '{}', '') = '' + AND COALESCE(legacy.value_json #>> '{}', '') <> ''; diff --git a/scripts/publish_site_batch.py b/scripts/publish_site_batch.py index 43a03b6..88c8a46 100644 --- a/scripts/publish_site_batch.py +++ b/scripts/publish_site_batch.py @@ -42,8 +42,9 @@ def parse_args() -> argparse.Namespace: group.add_argument("--ids", help="Comma-separated raw_post ids") group.add_argument("--all-ready", action="store_true", help="Publish every photo-ready post not yet published to site") group.add_argument("--all-ready-videos", action="store_true", help="Publish video-only ready posts") - parser.add_argument("--ghost-url", default="https://f-n8.ru") - parser.add_argument("--key-file", default="/opt/vk-parser/.site-poster-key") + parser.add_argument("--ghost-url", required=True) + parser.add_argument("--key-file", default="") + parser.add_argument("--fallback-title", default="Новость") parser.add_argument("--limit", type=int, default=0, help="Maximum posts to publish in this run; 0 means unlimited") parser.add_argument("--retry-failed", action="store_true", help="Include previous site publish_failed rows") return parser.parse_args() @@ -63,11 +64,11 @@ def ghost_token(key: str) -> str: return f"{header}.{payload}.{signature}" -def split_post_text(value: str) -> tuple[str, str]: +def split_post_text(value: str, fallback_title: str) -> tuple[str, str]: lines = [line.strip() for line in str(value or "").replace("\r", "").split("\n")] while lines and not lines[0]: lines.pop(0) - title = lines.pop(0) if lines else "Новость Forma N8" + title = lines.pop(0) if lines else fallback_title while lines and (not lines[0] or SEPARATOR_RE.fullmatch(lines[0])): lines.pop(0) while lines and (not lines[-1] or HASHTAG_LINE_RE.fullmatch(lines[-1])): @@ -296,6 +297,8 @@ async def main() -> None: args = parse_args() key = os.environ.get("SITE_POSTER_GHOST_ADMIN_KEY", "").strip() if not key: + if not args.key_file: + raise RuntimeError("SITE_POSTER_GHOST_ADMIN_KEY or --key-file is required") key = Path(args.key_file).read_text(encoding="utf-8").strip() pool = await get_pool() if args.all_ready or args.all_ready_videos: @@ -452,7 +455,7 @@ async def main() -> None: feature = uploaded_photos[0] if uploaded_photos else thumbnail if not feature: raise RuntimeError("no downloadable site media") - title, body = split_post_text(post.get("final_text") or post.get("rewritten_text") or "") + title, body = split_post_text(post.get("final_text") or post.get("rewritten_text") or "", args.fallback_title) producer = str(post.get("source_name") or "").strip() content = gallery_html(uploaded_photos[1:], title) + videos_html(uploaded_videos, title, feature["url"]) + paragraphs(body) attachments = uploaded_photos + uploaded_videos + ([thumbnail] if thumbnail else []) @@ -513,7 +516,7 @@ def run_locked() -> None: if fcntl is None: asyncio.run(main()) return - with Path("/tmp/forma-n8-site-poster.lock").open("w") as lock_file: + with Path("/tmp/site-poster.lock").open("w") as lock_file: try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: diff --git a/scripts/update_ghost_about.mjs b/scripts/update_ghost_about.mjs index 9ac96ae..75c02aa 100644 --- a/scripts/update_ghost_about.mjs +++ b/scripts/update_ghost_about.mjs @@ -1,8 +1,12 @@ import {createHmac} from "node:crypto"; import {readFileSync} from "node:fs"; -const baseUrl = "https://f-n8.ru"; -const [keyId, secret] = readFileSync("/root/ghost-admin-api-key", "utf8").trim().split(":"); +const baseUrl = (process.env.GHOST_URL || "").replace(/\/$/, ""); +const keyFile = process.env.GHOST_ADMIN_KEY_FILE || ""; +if (!baseUrl || !keyFile) { + throw new Error("GHOST_URL and GHOST_ADMIN_KEY_FILE are required"); +} +const [keyId, secret] = readFileSync(keyFile, "utf8").trim().split(":"); const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); const now = Math.floor(Date.now() / 1000); const unsigned = `${encode({alg: "HS256", typ: "JWT", kid: keyId})}.${encode({iat: now, exp: now + 300, aud: "/admin/"})}`; diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 98c3e92..a4e34d5 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -71,6 +71,11 @@ PROVIDER_OPTIONS = [ {"value": "openai_compatible", "label": "OpenAI-compatible API"}, ] +SITE_POSTER_PROVIDER_OPTIONS = [ + {"value": "", "label": "Не выбран"}, + {"value": "ghost", "label": "Ghost"}, +] + PROMPT_HINTS = { "ai_qualifier_prompt": { "title": "Свободная инструкция квалификатора", @@ -284,6 +289,10 @@ SETTING_ORDER = { "max_poster_dry_run", ], "Site Poster": [ + "site_poster_provider", + "site_poster_base_url", + "site_poster_secret_file", + "site_poster_fallback_title", "site_poster_interval_sec", "site_poster_photo_batch_size", "site_poster_video_batch_size", @@ -450,6 +459,7 @@ def base_context(request: Request, user: dict | None, **extra: Any) -> dict[str, "app_env": settings.app_env, "app_title": settings.display_admin_title, "site_title": settings.display_site_title, + "site_poster_provider_options": SITE_POSTER_PROVIDER_OPTIONS, } ctx.update(extra) return ctx diff --git a/src/vk_parser_app/templates/workers.html b/src/vk_parser_app/templates/workers.html index 18d5879..838a33f 100644 --- a/src/vk_parser_app/templates/workers.html +++ b/src/vk_parser_app/templates/workers.html @@ -325,6 +325,13 @@ {% endfor %} + + {% elif s.key == "site_poster_provider" %} + {% elif s.key == "ai_qualifier_model" %}