mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 21:32:00 +03:00
Fix adversarial code review defects
This commit is contained in:
parent
5658cff323
commit
f77fafd240
12 changed files with 98 additions and 50 deletions
|
|
@ -5,7 +5,7 @@ import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ STANCES = ["sincere", "ironic", "bitter"]
|
||||||
# фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри
|
# фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри
|
||||||
# одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения
|
# одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения
|
||||||
# механически невозможными на этапе генерации, а не отлавливаемыми постфактум.
|
# механически невозможными на этапе генерации, а не отлавливаемыми постфактум.
|
||||||
THEME_PATTERN = "^[a-z][a-z0-9_]{2,29}$"
|
THEME_PATTERN = "^[a-z0-9_-]{2,30}$"
|
||||||
|
|
||||||
|
|
||||||
def _enum(values: list[str]) -> dict:
|
def _enum(values: list[str]) -> dict:
|
||||||
|
|
@ -193,9 +193,13 @@ def call_ollama(settings: OllamaSettings, prompt: str) -> dict:
|
||||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=settings.timeout) as response:
|
with urllib.request.urlopen(request, timeout=settings.timeout) as response:
|
||||||
body = json.loads(response.read().decode("utf-8"))
|
body = json.loads(response.read().decode("utf-8"))
|
||||||
return json.loads(body["message"]["content"])
|
return json.loads(body["message"]["content"])
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"HTTP Error {e.code}: {e.reason}\n{error_body}") from e
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write_json(path: Path, data: dict) -> None:
|
def _atomic_write_json(path: Path, data: dict) -> None:
|
||||||
|
|
@ -241,14 +245,15 @@ def analyze_tracks(
|
||||||
|
|
||||||
print(f"Разбор текстов: {len(pending)} треков, модель {settings.model}")
|
print(f"Разбор текстов: {len(pending)} треков, модель {settings.model}")
|
||||||
failed = 0
|
failed = 0
|
||||||
|
current_themes = top_themes(cache)
|
||||||
for i, (tid, text, text_hash) in enumerate(pending, 1):
|
for i, (tid, text, text_hash) in enumerate(pending, 1):
|
||||||
info = meta.get(tid, {"title": tid, "artists": []})
|
info = meta.get(tid, {"title": tid, "artists": []})
|
||||||
prompt = build_prompt(info["title"], info["artists"], text, top_themes(cache))
|
prompt = build_prompt(info["title"], info["artists"], text, current_themes)
|
||||||
stamp = {
|
stamp = {
|
||||||
"model": settings.model,
|
"model": settings.model,
|
||||||
"prompt_version": settings.prompt_version,
|
"prompt_version": settings.prompt_version,
|
||||||
"lyrics_hash": text_hash,
|
"lyrics_hash": text_hash,
|
||||||
"analyzed_at": datetime.now().isoformat(timespec="seconds"),
|
"analyzed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = call(settings, prompt)
|
data = call(settings, prompt)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ def _existing_track_ids(client: Client, playlist: Playlist, max_attempts: int =
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
full = with_retries(lambda: client.users_playlists(playlist.kind))
|
full = with_retries(lambda: client.users_playlists(playlist.kind))
|
||||||
if full is not None:
|
if full is not None:
|
||||||
return {t.track_id for t in full.tracks}
|
return {str(t.track_id).split(":")[0] for t in full.tracks}
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
wait = 2.0 * (2**attempt)
|
wait = 2.0 * (2**attempt)
|
||||||
print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...")
|
print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...")
|
||||||
|
|
@ -91,7 +91,7 @@ def _insert_track_verified(
|
||||||
fresh = with_retries(lambda: client.users_playlists(playlist.kind))
|
fresh = with_retries(lambda: client.users_playlists(playlist.kind))
|
||||||
if fresh is not None:
|
if fresh is not None:
|
||||||
revision = fresh.revision or revision
|
revision = fresh.revision or revision
|
||||||
if track_key in {t.track_id for t in fresh.tracks}:
|
if track_key in {str(t.track_id).split(":")[0] for t in fresh.tracks}:
|
||||||
return revision
|
return revision
|
||||||
if last_attempt:
|
if last_attempt:
|
||||||
raise
|
raise
|
||||||
|
|
@ -195,7 +195,7 @@ def dedupe_playlists(client: Client, delay: float, dry_run: bool = True) -> None
|
||||||
print(f" не удалось прочитать «{playlist.title}», пропускаю")
|
print(f" не удалось прочитать «{playlist.title}», пропускаю")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ids = [t.track_id for t in full.tracks]
|
ids = [str(t.track_id).split(":")[0] for t in full.tracks]
|
||||||
ranges = find_duplicate_ranges(ids)
|
ranges = find_duplicate_ranges(ids)
|
||||||
if not ranges:
|
if not ranges:
|
||||||
continue
|
continue
|
||||||
|
|
@ -210,8 +210,16 @@ def dedupe_playlists(client: Client, delay: float, dry_run: bool = True) -> None
|
||||||
|
|
||||||
revision = full.revision or 1
|
revision = full.revision or 1
|
||||||
for frm, to in sorted(ranges, key=lambda r: -r[0]):
|
for frm, to in sorted(ranges, key=lambda r: -r[0]):
|
||||||
updated = client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision)
|
try:
|
||||||
revision = updated.revision if updated is not None and updated.revision is not None else revision + 1
|
updated = with_retries(lambda: client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision))
|
||||||
|
if updated is not None and updated.revision is not None:
|
||||||
|
revision = updated.revision
|
||||||
|
except NetworkError as e:
|
||||||
|
print(f" сетевая ошибка при удалении из «{playlist.title}» ({e}), прерываю")
|
||||||
|
break
|
||||||
|
except BadRequestError as e:
|
||||||
|
print(f" ошибка при удалении из «{playlist.title}» ({e}), прерываю")
|
||||||
|
break
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
print(f"{playlist.title}: удалено {n} дублей")
|
print(f"{playlist.title}: удалено {n} дублей")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ def load_token(token_file: Path) -> str | None:
|
||||||
|
|
||||||
def save_token(token_file: Path, token: str) -> None:
|
def save_token(token_file: Path, token: str) -> None:
|
||||||
token_file.parent.mkdir(parents=True, exist_ok=True)
|
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
token_file.write_text(token, encoding="utf-8")
|
import os
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||||
|
with open(os.open(token_file, flags, 0o600), "w", encoding="utf-8") as f:
|
||||||
|
f.write(token)
|
||||||
|
|
||||||
|
|
||||||
def _on_code(code: DeviceCode) -> None:
|
def _on_code(code: DeviceCode) -> None:
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,8 @@ def cmd_apply(args: argparse.Namespace) -> None:
|
||||||
|
|
||||||
catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir)
|
catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir)
|
||||||
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
||||||
lang_cache = language.load_lang_cache(cfg.cache_dir)
|
ids_needing_lang = [str(t["id"]) for t in tracks_cache.values() if t["lyrics_available"]]
|
||||||
|
lang_cache = language.fetch_api_languages(client, ids_needing_lang, cfg.cache_dir, cfg.lyrics_request_delay)
|
||||||
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
|
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
|
||||||
|
|
||||||
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)
|
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import tomllib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -29,6 +29,7 @@ class Config:
|
||||||
|
|
||||||
def load_config(path: Path | None = None) -> Config:
|
def load_config(path: Path | None = None) -> Config:
|
||||||
path = path or ROOT / "config.toml"
|
path = path or ROOT / "config.toml"
|
||||||
|
try:
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
data = tomllib.load(f)
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
|
@ -50,3 +51,5 @@ def load_config(path: Path | None = None) -> Config:
|
||||||
cache_dir=ROOT / data["paths"]["cache_dir"],
|
cache_dir=ROOT / data["paths"]["cache_dir"],
|
||||||
out_dir=ROOT / data["paths"]["out_dir"],
|
out_dir=ROOT / data["paths"]["out_dir"],
|
||||||
)
|
)
|
||||||
|
except (KeyError, tomllib.TOMLDecodeError) as e:
|
||||||
|
raise SystemExit(f"Ошибка в конфигурационном файле {path}: {type(e).__name__}: {e}") from e
|
||||||
|
|
|
||||||
|
|
@ -492,5 +492,7 @@ def render_lyrics_digest(tracks_cache: dict[str, dict], analysis: dict[str, dict
|
||||||
def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path:
|
def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path:
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
out_file = out_dir / filename
|
out_file = out_dir / filename
|
||||||
out_file.write_text(text, encoding="utf-8")
|
tmp = out_file.with_suffix(".tmp")
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
tmp.replace(out_file)
|
||||||
return out_file
|
return out_file
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,10 @@ def fetch_liked_tracks(
|
||||||
# пересчитанному из albums[0] вернувшегося Track - для трека, доступного на нескольких
|
# пересчитанному из albums[0] вернувшегося Track - для трека, доступного на нескольких
|
||||||
# альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут.
|
# альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут.
|
||||||
# Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый".
|
# Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый".
|
||||||
for original_id, track in zip(batch, tracks):
|
by_id = {str(t.id): t for t in tracks}
|
||||||
|
for original_id in batch:
|
||||||
|
track = by_id.get(original_id.split(":")[0])
|
||||||
|
if track is not None:
|
||||||
cache[original_id] = serialize_track(track, added_at=added_at_by_id.get(original_id.split(":")[0]))
|
cache[original_id] = serialize_track(track, added_at=added_at_by_id.get(original_id.split(":")[0]))
|
||||||
_atomic_write_json(cache_file, cache)
|
_atomic_write_json(cache_file, cache)
|
||||||
print(f" загружено {len(cache)}/{len(all_ids)}")
|
print(f" загружено {len(cache)}/{len(all_ids)}")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from yandex_music import Client, Genre
|
from yandex_music import Client, Genre
|
||||||
|
|
||||||
|
from .fetch import _atomic_write_json
|
||||||
from .ymclient import with_retries
|
from .ymclient import with_retries
|
||||||
|
|
||||||
GENRE_CATALOG_FILE = "genre_catalog.json"
|
GENRE_CATALOG_FILE = "genre_catalog.json"
|
||||||
|
|
@ -114,7 +115,7 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
|
||||||
roots = with_retries(lambda: client.genres())
|
roots = with_retries(lambda: client.genres())
|
||||||
flat = flatten_catalog(roots)
|
flat = flatten_catalog(roots)
|
||||||
cache_file = cache_dir / GENRE_CATALOG_FILE
|
cache_file = cache_dir / GENRE_CATALOG_FILE
|
||||||
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8")
|
_atomic_write_json(cache_file, flat)
|
||||||
return flat
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ def alphabet_heuristic(title: str, artists: list[str]) -> str | None:
|
||||||
text = " ".join([title, *artists])
|
text = " ".join([title, *artists])
|
||||||
cyrillic, latin = _script_counts(text)
|
cyrillic, latin = _script_counts(text)
|
||||||
if cyrillic == 0 and latin == 0:
|
if cyrillic == 0 and latin == 0:
|
||||||
|
if any(ch.isalpha() for ch in text):
|
||||||
|
return "INT"
|
||||||
return None
|
return None
|
||||||
if cyrillic > latin:
|
if cyrillic > latin:
|
||||||
return "RU"
|
return "RU"
|
||||||
|
|
@ -99,14 +101,14 @@ def load_lang_cache(cache_dir: Path) -> dict[str, str | None]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_one_language(client: Client, track_id: str) -> str | None:
|
def _fetch_supplement(client: Client, track_id: str) -> tuple[str | None, str | None]:
|
||||||
try:
|
try:
|
||||||
supplement = with_retries(lambda: client.track_supplement(track_id))
|
supplement = with_retries(lambda: client.track_supplement(track_id))
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
return None
|
return None, None
|
||||||
if supplement is None or supplement.lyrics is None:
|
if supplement is None or supplement.lyrics is None:
|
||||||
return None
|
return None, None
|
||||||
return supplement.lyrics.text_language
|
return supplement.lyrics.text_language, supplement.lyrics.full_lyrics
|
||||||
|
|
||||||
|
|
||||||
def fetch_api_languages(
|
def fetch_api_languages(
|
||||||
|
|
@ -119,17 +121,28 @@ def fetch_api_languages(
|
||||||
cache_file = cache_dir / LYRICS_LANG_CACHE_FILE
|
cache_file = cache_dir / LYRICS_LANG_CACHE_FILE
|
||||||
cache = load_lang_cache(cache_dir)
|
cache = load_lang_cache(cache_dir)
|
||||||
|
|
||||||
|
text_cache_file = cache_dir / "lyrics_text.json"
|
||||||
|
if text_cache_file.exists():
|
||||||
|
text_cache = json.loads(text_cache_file.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
text_cache = {}
|
||||||
|
|
||||||
missing = [tid for tid in track_ids if tid not in cache]
|
missing = [tid for tid in track_ids if tid not in cache]
|
||||||
if not missing:
|
if not missing:
|
||||||
return cache
|
return cache
|
||||||
|
|
||||||
print(f"Запрос языка текста песни для {len(missing)} треков...")
|
print(f"Запрос языка текста песни для {len(missing)} треков...")
|
||||||
for i, tid in enumerate(missing, 1):
|
for i, tid in enumerate(missing, 1):
|
||||||
cache[tid] = _fetch_one_language(client, tid)
|
lang, text = _fetch_supplement(client, tid)
|
||||||
|
cache[tid] = lang
|
||||||
|
if text is not None:
|
||||||
|
text_cache[tid] = text
|
||||||
if i % 20 == 0:
|
if i % 20 == 0:
|
||||||
_atomic_write_json(cache_file, cache)
|
_atomic_write_json(cache_file, cache)
|
||||||
|
_atomic_write_json(text_cache_file, text_cache)
|
||||||
print(f" обработано {i}/{len(missing)}")
|
print(f" обработано {i}/{len(missing)}")
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
||||||
_atomic_write_json(cache_file, cache)
|
_atomic_write_json(cache_file, cache)
|
||||||
|
_atomic_write_json(text_cache_file, text_cache)
|
||||||
return cache
|
return cache
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ def build_rows(
|
||||||
for t, fine, lang in prelim:
|
for t, fine, lang in prelim:
|
||||||
if group_sizes[(fine, lang)] < small_group_min:
|
if group_sizes[(fine, lang)] < small_group_min:
|
||||||
bucket = genres.coarse_of(fine)
|
bucket = genres.coarse_of(fine)
|
||||||
|
if bucket == fine:
|
||||||
|
bucket = "other"
|
||||||
else:
|
else:
|
||||||
bucket = fine
|
bucket = fine
|
||||||
playlist = classify.playlist_name(bucket, lang)
|
playlist = classify.playlist_name(bucket, lang)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from yandex_music.exceptions import NotFoundError, UnauthorizedError
|
||||||
from . import fetch
|
from . import fetch
|
||||||
from .ymclient import chunked, with_retries
|
from .ymclient import chunked, with_retries
|
||||||
|
|
||||||
HOST_RE = re.compile(r"^(?:www\.)?music\.yandex\.[a-z]{2,3}$", re.IGNORECASE)
|
HOST_RE = re.compile(r"^(?:www\.|m\.)?music\.yandex\.[a-z\.]+$", re.IGNORECASE)
|
||||||
# "lk." (личный кабинет) - префикс у ссылок на персональные плейлисты (например, "Мне нравится"),
|
# "lk." (личный кабинет) - префикс у ссылок на персональные плейлисты (например, "Мне нравится"),
|
||||||
# в отличие от чистого uuid у редакционных плейлистов - подтверждено вживую через client.playlist():
|
# в отличие от чистого uuid у редакционных плейлистов - подтверждено вживую через client.playlist():
|
||||||
# без префикса API отвечает NotFoundError, префикс - часть самого uuid, а не что-то, что надо отрезать.
|
# без префикса API отвечает NotFoundError, префикс - часть самого uuid, а не что-то, что надо отрезать.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Iterator, List, TypeVar
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from yandex_music import Client
|
from yandex_music import Client
|
||||||
from yandex_music.exceptions import BadRequestError, NetworkError, NotFoundError
|
from yandex_music.exceptions import BadRequestError, NetworkError, NotFoundError
|
||||||
|
|
@ -16,7 +17,9 @@ def make_client(token: str, request_timeout: float = 20) -> Client:
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def chunked(items: List[T], size: int) -> Iterator[List[T]]:
|
def chunked(items: Sequence[T], size: int) -> Iterator[Sequence[T]]:
|
||||||
|
if size <= 0:
|
||||||
|
raise ValueError("size must be > 0")
|
||||||
for i in range(0, len(items), size):
|
for i in range(0, len(items), size):
|
||||||
yield items[i : i + size]
|
yield items[i : i + size]
|
||||||
|
|
||||||
|
|
@ -28,17 +31,21 @@ def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float =
|
||||||
(данные точно не найдены / запрос точно невалиден), а не сбои сети, хотя формально
|
(данные точно не найдены / запрос точно невалиден), а не сбои сети, хотя формально
|
||||||
оба тоже унаследованы от NetworkError. Повтор такого запроса даст тот же результат.
|
оба тоже унаследованы от NetworkError. Повтор такого запроса даст тот же результат.
|
||||||
"""
|
"""
|
||||||
last_exc: NetworkError | None = None
|
if max_attempts <= 0:
|
||||||
|
max_attempts = 1
|
||||||
|
last_exc: Exception | None = None
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
return fn()
|
return fn()
|
||||||
except (NotFoundError, BadRequestError):
|
except (NotFoundError, BadRequestError):
|
||||||
raise
|
raise
|
||||||
except NetworkError as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, (TypeError, ValueError, AttributeError, NameError)):
|
||||||
|
raise
|
||||||
last_exc = e
|
last_exc = e
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
wait = base_delay * (2**attempt)
|
wait = base_delay * (2**attempt)
|
||||||
print(f" сетевая ошибка ({e}), повтор через {wait:.0f}с...")
|
print(f" сетевая ошибка ({type(e).__name__}: {e}), повтор через {wait:.0f}с...")
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
|
|
||||||
assert last_exc is not None
|
assert last_exc is not None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue