Update UI and migrations, fix dockerfile

This commit is contained in:
Your Name
2026-07-18 21:29:05 +05:00
parent 580c75cbf6
commit 26ef145ccf
95 changed files with 9541 additions and 111 deletions
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import html
import json
import os
from pathlib import Path
from typing import Any
from aiogram.types import FSInputFile
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.workers.vk_storage_uploader import TMP_DIR, TMP_PREFIX, TelegramStorageUploader
def jlog(event: str, **payload: Any) -> None:
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]]:
rows = await worker.pool.fetch(
"""
SELECT m.*, rp.original_url AS post_url
FROM raw_post_media m
JOIN raw_posts rp ON rp.id=m.raw_post_id
WHERE m.media_type='video'
AND m.status='pending'
AND COALESCE(m.tg_file_id, m.storage_attachment_id, '') = ''
AND COALESCE(m.error, '') ILIKE '%timeout%'
AND COALESCE(m.duration_sec, 0) <= $1
ORDER BY COALESCE(rp.publication_status, 'pending')='published', m.id DESC
LIMIT $2
""",
worker.video_max_duration_sec,
limit,
)
return [dict(row) for row in rows]
async def send_video_to_storage(worker: TelegramStorageUploader, media: dict[str, Any], path: str) -> None:
caption = (
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>'
)
message = await worker.tg_retry(
lambda: worker.bot.send_video(
video=FSInputFile(path, filename=f"video_{int(media['id'])}.mp4"),
caption=caption[: worker.caption_limit],
parse_mode="HTML",
**worker.chat_kwargs(),
)
)
if not message.video:
raise RuntimeError("Telegram accepted message without video payload")
await worker.mark_media_uploaded(int(media["id"]), message.video.file_id, message.video.file_unique_id)
async def main() -> None:
limit = int(os.getenv("BACKFILL_LIMIT", "200"))
worker = TelegramStorageUploader()
await worker.init()
videos = await load_pending_videos(worker, limit)
jlog("start", count=len(videos), limit=limit)
ok = 0
failed = 0
link_only = 0
try:
for media in videos:
media_id = int(media["id"])
raw_post_id = int(media["raw_post_id"])
path = str(TMP_DIR / f"{TMP_PREFIX}backfill_{raw_post_id}_{media_id}.mp4")
jlog(
"video_start",
media_id=media_id,
raw_post_id=raw_post_id,
url=media.get("original_url"),
duration_sec=media.get("duration_sec"),
)
try:
info = await worker.download_video(str(media.get("original_url") or ""), path)
if not info:
await worker.mark_media_failed_attempt(media_id, "video download failed")
failed += 1
jlog("video_failed", media_id=media_id, raw_post_id=raw_post_id, error="video download failed")
continue
if info.get("error"):
if info.get("permanent"):
await worker.mark_media_link_only(media_id, str(info["error"]))
link_only += 1
else:
await worker.mark_media_failed_attempt(media_id, str(info["error"]))
failed += 1
jlog("video_failed", media_id=media_id, raw_post_id=raw_post_id, error=info.get("error"))
continue
await send_video_to_storage(worker, media, path)
ok += 1
jlog("video_uploaded", media_id=media_id, raw_post_id=raw_post_id, size_bytes=info.get("size_bytes"))
await asyncio.sleep(worker.media_upload_delay_sec)
except Exception as exc:
await worker.mark_media_failed_attempt(media_id, str(exc))
failed += 1
jlog("video_failed", media_id=media_id, raw_post_id=raw_post_id, error=str(exc))
finally:
Path(path).unlink(missing_ok=True)
finally:
await worker.close()
if worker.pool:
await worker.pool.close()
jlog("done", ok=ok, failed=failed, link_only=link_only, total=len(videos))
if __name__ == "__main__":
asyncio.run(main())
+524
View File
@@ -0,0 +1,524 @@
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import hmac
import html
import io
import json
import os
import re
import subprocess
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
try:
import fcntl
except ImportError: # pragma: no cover - production runs on Linux
fcntl = None
import aiohttp
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from PIL import Image, ImageOps
from vk_parser_app.config import settings
from vk_parser_app.db import close_pool, fetch_setting, get_pool
SEPARATOR_RE = re.compile(r"^━+$")
HASHTAG_LINE_RE = re.compile(r"^(?:#[\wа-яё]+\s*)+$", re.IGNORECASE)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Publish selected accepted posts to Ghost")
group = parser.add_mutually_exclusive_group(required=True)
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("--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()
def ghost_token(key: str) -> str:
key_id, secret = key.strip().split(":", 1)
now = int(time.time())
def encode(value: dict[str, Any] | bytes) -> str:
raw = value if isinstance(value, bytes) else json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
header = encode({"alg": "HS256", "typ": "JWT", "kid": key_id})
payload = encode({"iat": now, "exp": now + 300, "aud": "/admin/"})
signature = encode(hmac.new(bytes.fromhex(secret), f"{header}.{payload}".encode(), hashlib.sha256).digest())
return f"{header}.{payload}.{signature}"
def split_post_text(value: 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"
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])):
lines.pop()
body = "\n".join(lines).strip()
return title[:255], body
def paragraphs(value: str) -> str:
chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n", value) if chunk.strip()]
return "".join(f"<p>{html.escape(chunk).replace(chr(10), '<br>')}</p>" for chunk in chunks)
def excerpt(value: str, limit: int = 260) -> str:
clean = re.sub(r"\s+", " ", value).strip()
if len(clean) <= limit:
return clean
return clean[: limit - 1].rsplit(" ", 1)[0] + ""
def compress_photo(data: bytes) -> tuple[bytes, int, int]:
with Image.open(io.BytesIO(data)) as source:
image = ImageOps.exif_transpose(source)
if image.mode not in {"RGB", "RGBA"}:
image = image.convert("RGB")
image.thumbnail((2000, 2000), Image.Resampling.LANCZOS)
output = io.BytesIO()
image.save(output, "WEBP", quality=82, method=6)
return output.getvalue(), image.width, image.height
def video_thumbnail(data: bytes) -> tuple[bytes, int, int]:
video_path = ""
image_path = ""
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as video_file:
video_file.write(data)
video_path = video_file.name
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as image_file:
image_path = image_file.name
command = [
"ffmpeg", "-y", "-ss", "1", "-i", video_path,
"-frames:v", "1", "-vf", "scale='min(2000,iw)':-2", "-q:v", "3", image_path,
]
result = subprocess.run(command, capture_output=True, timeout=90)
if result.returncode != 0 or not Path(image_path).exists() or Path(image_path).stat().st_size == 0:
command[3] = "0"
result = subprocess.run(command, capture_output=True, timeout=90)
if result.returncode != 0:
raise RuntimeError("ffmpeg thumbnail extraction failed")
return compress_photo(Path(image_path).read_bytes())
finally:
for path in (video_path, image_path):
if path:
try:
os.unlink(path)
except FileNotFoundError:
pass
def normalize_video(data: bytes, duration: int, height: int | None) -> bytes:
max_bytes = 40 * 1024 * 1024
if len(data) <= max_bytes and (not height or height <= 720):
return data
input_path = ""
output_path = ""
try:
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as input_file:
input_file.write(data)
input_path = input_file.name
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as output_file:
output_path = output_file.name
safe_duration = max(1, int(duration or 1))
target_total_kbps = int((38 * 1024 * 8) / safe_duration)
video_kbps = max(280, min(2800, target_total_kbps - 96))
command = [
"ffmpeg", "-y", "-i", input_path,
"-map_metadata", "-1", "-c:v", "libx264", "-preset", "fast",
"-b:v", f"{video_kbps}k", "-maxrate", f"{video_kbps}k", "-bufsize", f"{video_kbps * 2}k",
]
if height and int(height) > 720:
command.extend(["-vf", "scale=-2:720"])
command.extend(["-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", output_path])
result = subprocess.run(command, capture_output=True, timeout=max(180, safe_duration * 3))
if result.returncode != 0 or not Path(output_path).exists():
raise RuntimeError("ffmpeg video normalization failed")
normalized = Path(output_path).read_bytes()
if len(normalized) > 48 * 1024 * 1024:
raise RuntimeError(f"normalized video remains too large: {len(normalized)} bytes")
return normalized
finally:
for path in (input_path, output_path):
if path:
try:
os.unlink(path)
except FileNotFoundError:
pass
def gallery_html(images: list[dict[str, Any]], title: str) -> str:
if not images:
return ""
if len(images) == 1:
image = images[0]
return (
'<figure class="kg-card kg-image-card kg-width-wide">'
f'<img src="{html.escape(image["url"], quote=True)}" '
f'width="{image["width"]}" height="{image["height"]}" '
f'alt="{html.escape(title, quote=True)}" loading="lazy">'
"</figure>"
)
rows = []
for offset in range(0, len(images), 3):
cells = []
for image in images[offset : offset + 3]:
cells.append(
'<div class="kg-gallery-image">'
f'<img src="{html.escape(image["url"], quote=True)}" '
f'width="{image["width"]}" height="{image["height"]}" '
f'alt="{html.escape(title, quote=True)}" loading="lazy">'
"</div>"
)
rows.append('<div class="kg-gallery-row">' + "".join(cells) + "</div>")
return '<figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container">' + "".join(rows) + "</div></figure>"
def videos_html(videos: list[dict[str, Any]], title: str, poster_url: str) -> str:
return "".join(
'<figure class="kg-card kg-video-card kg-width-wide">'
f'<video src="{html.escape(video["url"], quote=True)}" '
f'poster="{html.escape(poster_url, quote=True)}" controls playsinline preload="metadata" '
f'aria-label="{html.escape(title, quote=True)}"></video>'
"</figure>"
for video in videos
)
def lexical_paragraph(text: str) -> dict[str, Any]:
return {
"children": [{"detail": 0, "format": 0, "mode": "normal", "style": "", "text": text, "type": "extended-text", "version": 1}],
"direction": None,
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1,
}
def video_lexical(videos: list[dict[str, Any]], body: str, thumbnail: dict[str, Any]) -> str:
children: list[dict[str, Any]] = []
for video in videos:
children.append(
{
"type": "video",
"version": 1,
"src": video["url"],
"caption": "",
"fileName": video["file_name"],
"mimeType": "video/mp4",
"width": video.get("width"),
"height": video.get("height"),
"duration": video.get("duration") or 0,
"thumbnailSrc": thumbnail["url"],
"customThumbnailSrc": "",
"thumbnailWidth": thumbnail["width"],
"thumbnailHeight": thumbnail["height"],
"cardWidth": "wide",
"loop": False,
}
)
children.extend(lexical_paragraph(chunk.strip()) for chunk in re.split(r"\n\s*\n", body) if chunk.strip())
return json.dumps(
{"root": {"children": children, "direction": None, "format": "", "indent": 0, "type": "root", "version": 1}},
ensure_ascii=False,
separators=(",", ":"),
)
class GhostClient:
def __init__(self, session: aiohttp.ClientSession, base_url: str, key: str) -> None:
self.session = session
self.base_url = base_url.rstrip("/")
self.key = key
def headers(self) -> dict[str, str]:
return {"Authorization": f"Ghost {ghost_token(self.key)}", "Accept-Version": "v6.0"}
async def upload_image(self, data: bytes, name: str, ref: str) -> str:
form = aiohttp.FormData()
form.add_field("file", data, filename=name, content_type="image/webp")
form.add_field("purpose", "image")
form.add_field("ref", ref)
async with self.session.post(
f"{self.base_url}/ghost/api/admin/images/upload/", data=form, headers=self.headers()
) as response:
payload = await response.json(content_type=None)
if response.status >= 300:
raise RuntimeError(f"Ghost image upload {response.status}: {payload}")
return str(payload["images"][0]["url"])
async def upload_media(self, data: bytes, name: str, content_type: str) -> str:
form = aiohttp.FormData()
form.add_field("file", data, filename=name, content_type=content_type)
async with self.session.post(
f"{self.base_url}/ghost/api/admin/media/upload/", data=form, headers=self.headers()
) as response:
payload = await response.json(content_type=None)
if response.status >= 300:
raise RuntimeError(f"Ghost media upload {response.status}: {payload}")
return str(payload["media"][0]["url"])
async def create_post(self, post: dict[str, Any]) -> dict[str, Any]:
suffix = "/" if "lexical" in post else "/?source=html"
async with self.session.post(
f"{self.base_url}/ghost/api/admin/posts{suffix}",
json={"posts": [post]},
headers=self.headers(),
) as response:
payload = await response.json(content_type=None)
if response.status >= 300:
raise RuntimeError(f"Ghost post create {response.status}: {payload}")
return dict(payload["posts"][0])
async def main() -> None:
args = parse_args()
key = Path(args.key_file).read_text(encoding="utf-8").strip()
pool = await get_pool()
if args.all_ready or args.all_ready_videos:
media_condition = (
"""EXISTS (
SELECT 1 FROM raw_post_media rpm
WHERE rpm.raw_post_id=rp.id AND rpm.media_type='video'
AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
) AND NOT EXISTS (
SELECT 1 FROM raw_post_media rpm
WHERE rpm.raw_post_id=rp.id AND rpm.media_type='photo'
AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
)"""
if args.all_ready_videos
else """EXISTS (
SELECT 1 FROM raw_post_media rpm
WHERE rpm.raw_post_id=rp.id AND rpm.media_type='photo'
AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
)"""
)
candidates = [
dict(row)
for row in await pool.fetch(
f"""
SELECT rp.id,
COALESCE(rp.final_category_tag,rp.rewrite_category_tag,'') AS category,
COALESCE(rp.final_source_tag,rp.rewrite_source_tag,s.tag,'') AS source
FROM raw_posts rp
JOIN sources s ON s.id=rp.source_id
JOIN content_categories cc ON cc.sort_order=COALESCE(rp.final_category_id,rp.rewrite_category_id)
LEFT JOIN post_publications pp ON pp.raw_post_id=rp.id AND pp.platform='site'
WHERE rp.status='storage_ready'
AND rp.rewrite_status='ready'
AND COALESCE(rp.editorial_status,'review') IN ('accepted','published','publish_failed')
AND cc.site_enabled=TRUE
AND COALESCE(pp.status,'pending') {"<> 'published'" if args.retry_failed else "= 'pending'"}
AND {media_condition}
ORDER BY COALESCE(rp.reviewed_at,rp.edited_at,rp.rewritten_at,rp.created_at),rp.id
"""
)
]
recent = [
dict(row)
for row in await pool.fetch(
"""
SELECT COALESCE(rp.final_category_tag,rp.rewrite_category_tag,'') AS category,
COALESCE(rp.final_source_tag,rp.rewrite_source_tag,s.tag,'') AS source
FROM post_publications pp
JOIN raw_posts rp ON rp.id=pp.raw_post_id
JOIN sources s ON s.id=rp.source_id
WHERE pp.platform='site' AND pp.status='published'
ORDER BY pp.published_at DESC NULLS LAST,pp.id DESC LIMIT 20
"""
)
]
selected: list[dict[str, Any]] = []
while candidates:
previous = selected[-1] if selected else (recent[0] if recent else {})
recent_window = recent + selected[-20:]
best = min(
range(len(candidates)),
key=lambda index: (
int(bool(candidates[index]["category"]) and candidates[index]["category"] == previous.get("category"))
+ int(bool(candidates[index]["source"]) and candidates[index]["source"] == previous.get("source")),
sum(3 for row in recent_window if row.get("category") == candidates[index]["category"])
+ sum(4 for row in recent_window if row.get("source") == candidates[index]["source"]),
index,
),
)
selected.append(candidates.pop(best))
ids = [int(row["id"]) for row in selected]
if args.limit > 0:
ids = ids[: args.limit]
else:
ids = [int(item) for item in str(args.ids or "").split(",") if item.strip()]
if not ids:
print("NO_READY_POSTS")
await close_pool()
return
print(f"QUEUE {len(ids)}")
rows = await pool.fetch(
"""
SELECT rp.*, s.name AS source_name, s.tag AS source_tag,
cc.site_name, cc.site_slug, cc.site_enabled,
pp.status AS site_publication_status
FROM raw_posts rp
JOIN sources s ON s.id=rp.source_id
JOIN content_categories cc ON cc.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
LEFT JOIN post_publications pp ON pp.raw_post_id=rp.id AND pp.platform='site'
WHERE rp.id=ANY($1::bigint[])
""",
ids,
)
by_id = {int(row["id"]): dict(row) for row in rows}
local_api = str(await fetch_setting("local_bot_api_url", settings.local_bot_api_url) or "").strip()
bot_session = AiohttpSession(api=TelegramAPIServer.from_base(local_api.rstrip("/"), is_local=True)) if local_api else AiohttpSession()
bot = Bot(settings.tg_bot_token, session=bot_session)
timeout = aiohttp.ClientTimeout(total=120)
published_base = datetime.now(timezone.utc) - timedelta(minutes=len(ids))
try:
async with aiohttp.ClientSession(timeout=timeout) as http_session:
ghost = GhostClient(http_session, args.ghost_url, key)
for position, raw_post_id in enumerate(ids):
post = by_id.get(raw_post_id)
if not post:
print(f"SKIP {raw_post_id}: missing or unmapped category")
continue
if not post.get("site_enabled"):
print(f"SKIP {raw_post_id}: category disabled for site")
continue
if post.get("site_publication_status") == "published":
print(f"SKIP {raw_post_id}: already published")
continue
try:
media_rows = await pool.fetch(
"""
SELECT * FROM raw_post_media
WHERE raw_post_id=$1 AND media_type IN ('photo','video')
AND COALESCE(editor_hidden,FALSE)=FALSE
AND COALESCE(tg_file_id,storage_attachment_id,'')<>''
ORDER BY sort_order,id
""",
raw_post_id,
)
uploaded_photos = []
uploaded_videos = []
thumbnail = None
for index, media in enumerate(media_rows):
file_id = str(media["tg_file_id"] or media["storage_attachment_id"])
telegram_file = await bot.get_file(file_id)
buffer = io.BytesIO()
await bot.download_file(telegram_file.file_path, destination=buffer)
data = buffer.getvalue()
if media["media_type"] == "photo":
compressed, width, height = compress_photo(data)
url = await ghost.upload_image(compressed, f"post-{raw_post_id}-{index + 1}.webp", f"raw-post-{raw_post_id}-{index + 1}")
uploaded_photos.append({"type": "photo", "url": url, "width": width, "height": height, "bytes": len(compressed)})
else:
data = normalize_video(data, int(media.get("duration_sec") or 0), media.get("height"))
url = await ghost.upload_media(data, f"post-{raw_post_id}-{index + 1}.mp4", "video/mp4")
uploaded_videos.append({
"type": "video", "url": url, "bytes": len(data),
"file_name": f"post-{raw_post_id}-{index + 1}.mp4",
"width": media.get("width"), "height": media.get("height"),
"duration": media.get("duration_sec") or 0,
})
if thumbnail is None:
compressed, width, height = video_thumbnail(data)
thumb_url = await ghost.upload_image(compressed, f"post-{raw_post_id}-video-cover.webp", f"raw-post-{raw_post_id}-video-cover")
thumbnail = {"type": "thumbnail", "url": thumb_url, "width": width, "height": height, "bytes": len(compressed)}
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 "")
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 [])
payload = {
"title": title,
"status": "published",
"published_at": (published_base + timedelta(minutes=position)).isoformat(),
"custom_excerpt": excerpt(body),
"meta_description": excerpt(body),
"feature_image": feature["url"],
"feature_image_alt": title,
"tags": [
{"name": str(post["site_name"]), "slug": str(post["site_slug"])},
{"name": f"#producer:{producer}"},
{"name": f"#raw-post:{raw_post_id}"},
],
}
if uploaded_videos and not uploaded_photos:
payload["lexical"] = video_lexical(uploaded_videos, body, thumbnail)
else:
payload["html"] = content
created = await ghost.create_post(payload)
await pool.execute(
"""
INSERT INTO post_publications(raw_post_id,platform,status,target_id,external_id,url,error,attachments_json,published_at)
VALUES($1,'site','published',$2,$3,$4,NULL,$5::jsonb,NOW())
ON CONFLICT(raw_post_id,platform) DO UPDATE SET
status='published',target_id=EXCLUDED.target_id,external_id=EXCLUDED.external_id,
url=EXCLUDED.url,error=NULL,attachments_json=EXCLUDED.attachments_json,
published_at=NOW(),updated_at=NOW()
""",
raw_post_id,
args.ghost_url,
str(created["id"]),
str(created["url"]),
json.dumps(attachments, ensure_ascii=False),
)
print(f"PUBLISHED {raw_post_id} {created['url']} media={len(attachments)} bytes={sum(x['bytes'] for x in attachments)}")
except Exception as exc:
await pool.execute(
"""
INSERT INTO post_publications(raw_post_id,platform,status,target_id,error,updated_at)
VALUES($1,'site','publish_failed',$2,$3,NOW())
ON CONFLICT(raw_post_id,platform) DO UPDATE SET
status='publish_failed',target_id=EXCLUDED.target_id,error=EXCLUDED.error,updated_at=NOW()
""",
raw_post_id,
args.ghost_url,
str(exc)[:2000],
)
print(f"FAILED {raw_post_id}: {exc}")
finally:
await bot.session.close()
await close_pool()
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:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("ALREADY_RUNNING")
return
asyncio.run(main())
if __name__ == "__main__":
run_locked()
+35
View File
@@ -0,0 +1,35 @@
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 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/"})}`;
const token = `${unsigned}.${createHmac("sha256", Buffer.from(secret, "hex")).update(unsigned).digest("base64url")}`;
const headers = {Authorization: `Ghost ${token}`, "Content-Type": "application/json"};
const oldText = "Мы собираем материалы производителей и профильных магазинов, проверяем их релевантность и приводим к единому удобному формату.";
const newText = "Ежедневно мы собираем новости снаряжения с сотен источников, отбираем лучшее и публикуем в одном месте!";
const response = await fetch(`${baseUrl}/ghost/api/admin/pages/slug/about/?formats=html`, {headers});
if (!response.ok) throw new Error(`Ghost page read failed: ${response.status} ${await response.text()}`);
const page = (await response.json()).pages[0];
const html = String(page.html || "");
if (!html.includes(oldText) && !String(page.custom_excerpt || "").includes(oldText)) {
throw new Error("Expected about text was not found");
}
const update = {
id: page.id,
updated_at: page.updated_at,
html: html.replaceAll(oldText, newText),
custom_excerpt: String(page.custom_excerpt || "").replaceAll(oldText, newText),
};
const saved = await fetch(`${baseUrl}/ghost/api/admin/pages/${page.id}/?source=html`, {
method: "PUT",
headers,
body: JSON.stringify({pages: [update]}),
});
if (!saved.ok) throw new Error(`Ghost page update failed: ${saved.status} ${await saved.text()}`);
const result = (await saved.json()).pages[0];
console.log(JSON.stringify({id: result.id, slug: result.slug, updated_at: result.updated_at}));