mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
243 lines
10 KiB
Python
243 lines
10 KiB
Python
# VESTI — Банк статей: markdown-бандлы + медиа.
|
||
# Спека: openspec/changes/tg-crawler-publisher-prototype/specs/news-store/spec.md
|
||
# Путь: bundles/<направление>/<YYYY-MM>/<slug>.md
|
||
# Медиа: bundles/<направление>/<YYYY-MM>/media/<slug>/
|
||
# frontmatter: title, date, direction, source, url, views, reactions, interest, status
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sqlite3
|
||
import unicodedata
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
BASE = Path(__file__).resolve().parent.parent
|
||
DB_PATH = BASE / "db" / "vesti.db"
|
||
BUNDLES_DIR = BASE / "bundles"
|
||
MEDIA_SRC = BASE / "media" / "media" # каталог, куда краулер складывает файлы
|
||
|
||
DIRECTION_LABELS = {
|
||
"linux": "Linux",
|
||
"tech": "Технологии",
|
||
"politics": "Политика",
|
||
"games": "Игры",
|
||
"electronics": "Электроника",
|
||
"llm": "LLM / AI",
|
||
}
|
||
|
||
|
||
def slugify(text: str, maxlen: int = 60) -> str:
|
||
"""Транслитерация + slug. Убирает URL/служебное. 'Omarchy — Arch Linux...' -> 'omarchy-arch-linux'."""
|
||
text = (text or "").strip()
|
||
# убираем markdown/эмодзи/служебное
|
||
text = re.sub(r"[*_`#\[\]()>|]", "", text)
|
||
# убираем URL (http, т.me, ссылки) — из них берём только куски без протокола
|
||
text = re.sub(r"https?://\S+|t\.me/\S+|ya\.cc/\S+", "", text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
# обрезаем до 6 слов — иначе слаг раздувается в белиберду
|
||
words_ = text.split()
|
||
if len(words_) > 6:
|
||
text = " ".join(words_[:6])
|
||
# транслитерация кириллицы (правильные буквосочетания)
|
||
tr_map = {
|
||
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
|
||
"ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
|
||
"н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
|
||
"ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sch",
|
||
"ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
|
||
"А": "A", "Б": "B", "В": "V", "Г": "G", "Д": "D", "Е": "E", "Ё": "E",
|
||
"Ж": "Zh", "З": "Z", "И": "I", "Й": "Y", "К": "K", "Л": "L", "М": "M",
|
||
"Н": "N", "О": "O", "П": "P", "Р": "R", "С": "S", "Т": "T", "У": "U",
|
||
"Ф": "F", "Х": "H", "Ц": "Ts", "Ч": "Ch", "Ш": "Sh", "Щ": "Sch",
|
||
"Ъ": "", "Ы": "Y", "Ь": "", "Э": "E", "Ю": "Yu", "Я": "Ya",
|
||
}
|
||
text = "".join(tr_map.get(ch, ch) for ch in text)
|
||
# нормализация юникода, оставляем латиницу/цифры/дефис
|
||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||
words = re.findall(r"[a-z0-9]+", text.lower())
|
||
slug = "-".join(words)[:maxlen].strip("-")
|
||
return slug or "untitled"
|
||
|
||
|
||
def get_post(conn, post_id: int):
|
||
return conn.execute(
|
||
"""SELECT p.*, s.slug AS source_slug, s.name AS source_name,
|
||
s.direction AS source_direction, s.lang AS source_lang
|
||
FROM posts p JOIN sources s ON s.id = p.source_id WHERE p.id = ?""",
|
||
(post_id,),
|
||
).fetchone()
|
||
|
||
|
||
def frontmatter(post, direction: str) -> dict:
|
||
"""Собирает YAML frontmatter по спеке."""
|
||
title = (post["text"] or "").replace("\n", " ").strip()
|
||
if len(title) > 120:
|
||
title = title[:117] + "..."
|
||
if not title:
|
||
title = f"{post['source_name']} — пост {post['tg_post_id']}"
|
||
reactions = json.loads(post["reactions"] or "{}")
|
||
# чистим ключи реакций: Telethon-объекты могли сохраниться как repr
|
||
clean_reactions = {}
|
||
for k, v in reactions.items():
|
||
key = k
|
||
if "emoticon" in str(key):
|
||
m = re.search(r"emoticon='([^']+)'", str(key))
|
||
if m:
|
||
key = m.group(1)
|
||
clean_reactions[key] = v
|
||
is_own = int(post.get("is_own") or 0) == 1
|
||
fm = {
|
||
"title": title,
|
||
"date": (post["published_at"] or "")[:10], # YYYY-MM-DD
|
||
"direction": direction,
|
||
"source": post["source_slug"],
|
||
"source_name": post["source_name"],
|
||
"url": post["url"],
|
||
"views": post["views"] or 0,
|
||
"reactions": clean_reactions,
|
||
"interest": None, # заполняется классификатором
|
||
"status": post["status"] or "new",
|
||
"content_type": post["content_type"],
|
||
"media": post["media_path"],
|
||
"tg_channel": post["tg_channel"],
|
||
"tg_post_id": post["tg_post_id"],
|
||
}
|
||
# СВОЙ контент: origin: own, source_url на оригинал, автор
|
||
if is_own:
|
||
fm["origin"] = "own"
|
||
if post.get("tg_channel") == "dedinit" and post.get("tg_post_id"):
|
||
fm["source_url"] = f"https://t.me/dedinit/{post['tg_post_id']}"
|
||
elif post.get("url"):
|
||
fm["source_url"] = post["url"]
|
||
fm["author"] = "Дед в АйТи"
|
||
else:
|
||
fm["origin"] = "external"
|
||
return fm
|
||
|
||
|
||
def make_bundle(post, direction: str, overwrite: bool = False) -> Path:
|
||
"""Создаёт бандл для поста. Возвращает путь к md или None, если уже есть."""
|
||
if "id" not in post.keys() and isinstance(post, sqlite3.Row):
|
||
post = dict(post)
|
||
slug = slugify(post["text"] or f"post-{post['tg_post_id']}")
|
||
month = (post["published_at"] or datetime.utcnow().isoformat())[:7] # YYYY-MM
|
||
bundle_dir = BUNDLES_DIR / direction / month
|
||
media_dir = bundle_dir / "media" / slug
|
||
md_path = bundle_dir / f"{slug}.md"
|
||
|
||
# коллизия слага (2 поста с одинаковым заголовком) — добавляем суффикс
|
||
if md_path.exists() and not overwrite:
|
||
existing = md_path.read_text(encoding="utf-8")
|
||
if f"tg_post_id: {post['tg_post_id']}" in existing:
|
||
return md_path # бандл уже создан для этого поста
|
||
# другой пост с таким же текстом — суффикс
|
||
i = 2
|
||
while (bundle_dir / f"{slug}-{i}.md").exists():
|
||
i += 1
|
||
slug = f"{slug}-{i}"
|
||
md_path = bundle_dir / f"{slug}.md"
|
||
media_dir = bundle_dir / "media" / slug
|
||
|
||
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# копируем медиа, если есть
|
||
media_rel = None
|
||
if post.get("media_path"):
|
||
src = MEDIA_SRC / os.path.basename(post["media_path"])
|
||
if src.exists():
|
||
media_dir.mkdir(parents=True, exist_ok=True)
|
||
dst = media_dir / src.name
|
||
if not dst.exists():
|
||
shutil.copy2(src, dst)
|
||
media_rel = f"media/{slug}/{src.name}"
|
||
else:
|
||
print(f" ! медиа не найдено на диске: {src}")
|
||
|
||
fm = frontmatter(post, direction)
|
||
if media_rel:
|
||
fm["media_local"] = media_rel
|
||
body = post["text"] or ""
|
||
content = (
|
||
"---\n"
|
||
+ yaml.safe_dump(fm, allow_unicode=True, sort_keys=False).strip()
|
||
+ "\n---\n\n"
|
||
+ body
|
||
+ "\n\n## Источники\n\n"
|
||
+ f"- [{post['source_name']}]({post['url']}) — {post['tg_channel']} ({post['tg_post_id']})\n"
|
||
)
|
||
md_path.write_text(content, encoding="utf-8")
|
||
return md_path
|
||
|
||
|
||
def create_bundle(post: dict, bundles_dir: Path | None = None, directions: list[str] | None = None) -> dict:
|
||
"""Создаёт бандл(ы) для поста (для веб-подтверждения). Возвращает {path, slug} или {paths}.
|
||
|
||
directions: список направлений — бандл создаётся по КАЖДОМУ (fan-out).
|
||
Если directions не задан — направление поста (source_direction/direction/linux).
|
||
"""
|
||
global BUNDLES_DIR
|
||
if bundles_dir is not None:
|
||
BUNDLES_DIR = Path(bundles_dir)
|
||
if not directions:
|
||
directions = [post.get("source_direction") or post.get("direction") or "linux"]
|
||
dirs = list(dict.fromkeys([d for d in directions if d]))
|
||
p = dict(post)
|
||
p.setdefault("source_slug", p.get("source_slug") or p.get("source_name") or "unknown")
|
||
p.setdefault("source_name", p.get("source_name") or p.get("source_slug") or "")
|
||
paths = []
|
||
first = None
|
||
for direction in dirs:
|
||
try:
|
||
path = make_bundle(p, direction)
|
||
# храним ОТНОСИТЕЛЬНЫЙ путь от bundles/ (для веба: /bundle/<rel>)
|
||
rel = str(path.relative_to(BUNDLES_DIR))
|
||
paths.append(rel)
|
||
if first is None:
|
||
first = rel
|
||
except Exception as e:
|
||
return {"error": str(e), "path": "", "slug": ""}
|
||
if first is None:
|
||
return {"error": "no bundles", "path": "", "slug": ""}
|
||
return {"path": first, "paths": paths, "slug": Path(first).stem}
|
||
|
||
|
||
def build_all(direction: str = "linux", statuses=("new",), limit: int = 0, overwrite: bool = False):
|
||
"""Собирает бандлы для постов заданного направления."""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
ph = ",".join("?" for _ in statuses)
|
||
rows = conn.execute(
|
||
f"""SELECT p.*, s.slug AS source_slug, s.name AS source_name,
|
||
s.direction AS source_direction, s.lang AS source_lang
|
||
FROM posts p JOIN sources s ON s.id = p.source_id
|
||
WHERE s.direction = ? AND p.status IN ({ph})
|
||
ORDER BY p.published_at DESC""",
|
||
(direction, *statuses),
|
||
).fetchall()
|
||
if limit:
|
||
rows = rows[:limit]
|
||
made = skipped = 0
|
||
for row in rows:
|
||
p = dict(row)
|
||
direction_eff = p.get("source_direction") or direction
|
||
try:
|
||
path = make_bundle(p, direction_eff, overwrite=overwrite)
|
||
except Exception as e:
|
||
print(f" ERR post {p.get('id')}: {e}")
|
||
continue
|
||
if path.exists():
|
||
made += 1
|
||
else:
|
||
skipped += 1
|
||
conn.close()
|
||
print(f"Бандлы: собрано {made}, пропущено {skipped}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
direction = sys.argv[1] if len(sys.argv) > 1 else "linux"
|
||
build_all(direction) |