Refactor code structure for improved readability and maintainability
CI / Quality Checks (push) Failing after 9s
CI / Test Suite (push) Successful in 10s

This commit is contained in:
ВяткинАртём
2026-05-27 17:06:39 +03:00
parent d06bd989f3
commit 59aaa9c4d7
31 changed files with 1416 additions and 543 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from .api import download, download_short, validate_session_file
from .api import PathInput, download, download_short, download_to_path, validate_session_file
from .exceptions import (
InvalidSessionError,
InvalidUrlError,
@@ -8,16 +8,19 @@ from .exceptions import (
VideoDownloadError,
YtShortsDownloaderError,
)
from .models import SessionValidation
from .models import DownloadedVideo, SessionValidation
__all__ = [
"DownloadedVideo",
"InvalidSessionError",
"InvalidUrlError",
"JsRuntimeUnavailableError",
"PathInput",
"SessionValidation",
"VideoDownloadError",
"YtShortsDownloaderError",
"download",
"download_short",
"download_to_path",
"validate_session_file",
]
-25
View File
@@ -1,25 +0,0 @@
from pathlib import Path
from .api import PathInput as PathInput
from .exceptions import InvalidSessionError as InvalidSessionError
from .exceptions import InvalidUrlError as InvalidUrlError
from .exceptions import JsRuntimeUnavailableError as JsRuntimeUnavailableError
from .exceptions import VideoDownloadError as VideoDownloadError
from .exceptions import YtShortsDownloaderError as YtShortsDownloaderError
from .models import SessionValidation as SessionValidation
def download(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path: ...
def download_short(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path: ...
def validate_session_file(path: Path) -> SessionValidation: ...
__all__: list[str]
+57 -37
View File
@@ -2,32 +2,56 @@ from __future__ import annotations
from os import PathLike
from pathlib import Path
from urllib.parse import urlparse
from tempfile import TemporaryDirectory
from .downloader import download_video
from .exceptions import InvalidSessionError, InvalidUrlError
from .session import validate_session_file
from .core.downloader import download_video
from .core.urls import validate_youtube_url
from .exceptions import InvalidSessionError, VideoDownloadError
from .models import DownloadedVideo
from .services.session import validate_session_file
PathInput = str | PathLike[str]
_YOUTUBE_HOST_SUFFIXES = ("youtube.com", "youtu.be")
__all__ = ["download", "download_short", "validate_session_file"]
__all__ = [
"PathInput",
"download",
"download_short",
"download_to_path",
"validate_session_file",
]
def download(
url: str,
session_path: PathInput,
) -> DownloadedVideo:
validate_youtube_url(url)
normalized_session_path = _normalize_session_path(session_path)
with TemporaryDirectory(prefix="yt-shorts-downloader-") as temporary_directory:
downloaded_file = download_video(
url=url,
output_dir=Path(temporary_directory),
session_path=normalized_session_path,
)
return _read_downloaded_video(downloaded_file)
def download_short(
url: str,
session_path: PathInput,
) -> DownloadedVideo:
return download(url=url, session_path=session_path)
def download_to_path(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path:
_validate_youtube_url(url)
normalized_session_path = Path(session_path).expanduser().resolve()
session_validation = validate_session_file(normalized_session_path)
if not session_validation.is_usable:
raise InvalidSessionError(session_validation.message)
validate_youtube_url(url)
normalized_session_path = _normalize_session_path(session_path)
normalized_output_dir = _normalize_output_dir(output_dir)
return download_video(
url=url,
@@ -36,29 +60,12 @@ def download(
)
def download_short(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path:
return download(url=url, session_path=session_path, output_dir=output_dir)
def _validate_youtube_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise InvalidUrlError("URL должна начинаться с http:// или https://")
host = parsed.netloc.lower()
if not host:
raise InvalidUrlError("Не удалось определить домен URL")
if not any(
host == suffix or host.endswith(f".{suffix}")
for suffix in _YOUTUBE_HOST_SUFFIXES
):
raise InvalidUrlError("Поддерживаются только ссылки YouTube")
def _normalize_session_path(session_path: PathInput) -> Path:
normalized_session_path = Path(session_path).expanduser().resolve()
session_validation = validate_session_file(normalized_session_path)
if not session_validation.is_usable:
raise InvalidSessionError(session_validation.message)
return normalized_session_path
def _normalize_output_dir(output_dir: PathInput | None) -> Path:
@@ -69,3 +76,16 @@ def _normalize_output_dir(output_dir: PathInput | None) -> Path:
normalized_output_dir.mkdir(parents=True, exist_ok=True)
return normalized_output_dir
def _read_downloaded_video(downloaded_file: Path) -> DownloadedVideo:
normalized_downloaded_file = downloaded_file.expanduser().resolve()
if not normalized_downloaded_file.exists():
raise VideoDownloadError("Скачивание завершилось без итогового файла на диске")
if normalized_downloaded_file.suffix.lower() != ".mp4":
raise VideoDownloadError("Публичный API поддерживает только итоговый MP4")
return DownloadedVideo(
filename=normalized_downloaded_file.name,
content=normalized_downloaded_file.read_bytes(),
)
-22
View File
@@ -1,22 +0,0 @@
from os import PathLike
from pathlib import Path
from .models import SessionValidation
type PathInput = str | PathLike[str]
def download(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path: ...
def download_short(
url: str,
session_path: PathInput,
*,
output_dir: PathInput | None = None,
) -> Path: ...
def validate_session_file(path: Path) -> SessionValidation: ...
__all__: list[str]
+3 -5
View File
@@ -4,15 +4,13 @@ import argparse
from collections.abc import Sequence
from pathlib import Path
from .api import download
from .api import download_to_path
from .exceptions import YtShortsDownloaderError
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Скачать YouTube Shorts через session file в Netscape cookie format."
),
description=("Скачать YouTube Shorts через session file в Netscape cookie format."),
)
parser.add_argument("url", help="Ссылка на YouTube Shorts")
parser.add_argument(
@@ -34,7 +32,7 @@ def main(argv: Sequence[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
downloaded_file = download(
downloaded_file = download_to_path(
url=args.url,
session_path=args.session_path,
output_dir=args.output_dir,
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from .downloader import download_video
from .runtime import JsRuntimeOptions, find_supported_js_runtimes
from .urls import validate_youtube_url
__all__ = [
"JsRuntimeOptions",
"download_video",
"find_supported_js_runtimes",
"validate_youtube_url",
]
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from pathlib import Path
from shutil import which
from typing import Final, cast
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError as YtDlpDownloadError
from yt_dlp.utils import UnsupportedError
from ..exceptions import InvalidUrlError, JsRuntimeUnavailableError, VideoDownloadError
from .runtime import JsRuntimeOptions, find_supported_js_runtimes
type Metadata = dict[str, object]
type YtDlpOptions = dict[str, object]
_DEFAULT_OUTTMPL: Final[str] = "%(title).200B [%(id)s].%(ext)s"
def download_video(url: str, output_dir: Path, session_path: Path) -> Path:
options = _build_yt_dlp_options(output_dir=output_dir, session_path=session_path)
try:
with YoutubeDL(options) as youtube_downloader:
extracted_info = youtube_downloader.extract_info(url, download=True)
except UnsupportedError as exc:
raise InvalidUrlError(f"yt-dlp не поддерживает эту ссылку: {exc}") from exc
except YtDlpDownloadError as exc:
raise VideoDownloadError(f"Не удалось скачать видео: {exc}") from exc
if not isinstance(extracted_info, dict):
raise VideoDownloadError("yt-dlp вернул неожиданный формат метаданных")
downloaded_file = _locate_downloaded_file(
info=cast(Metadata, extracted_info),
output_dir=output_dir,
)
if downloaded_file is None:
raise VideoDownloadError(
"Скачивание завершено, но итоговый путь к файлу определить не удалось"
)
if downloaded_file.suffix.lower() != ".mp4":
raise VideoDownloadError("Итоговый файл не является MP4")
return downloaded_file
def _build_yt_dlp_options(output_dir: Path, session_path: Path) -> YtDlpOptions:
js_runtimes = _get_supported_js_runtimes()
ffmpeg_available = which("ffmpeg") is not None
format_selector = "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]" if ffmpeg_available else "b[ext=mp4]"
return {
"cookiefile": str(session_path),
"format": format_selector,
"js_runtimes": js_runtimes,
"merge_output_format": "mp4",
"no_warnings": True,
"noplaylist": True,
"noprogress": True,
"outtmpl": str(output_dir / _DEFAULT_OUTTMPL),
"overwrites": False,
"quiet": True,
}
def _get_supported_js_runtimes() -> JsRuntimeOptions:
js_runtimes = find_supported_js_runtimes()
if js_runtimes is None:
raise JsRuntimeUnavailableError(
"Не найден поддерживаемый JavaScript runtime. Установите deno или Node.js 22+."
)
return js_runtimes
def _unwrap_info(info: Metadata) -> Metadata:
if info.get("_type") != "playlist":
return info
entries = info.get("entries")
if not isinstance(entries, list):
return info
for entry in entries:
if isinstance(entry, dict):
return cast(Metadata, entry)
return info
def _locate_downloaded_file(info: Metadata, output_dir: Path) -> Path | None:
normalized_info = _unwrap_info(info)
requested_downloads = normalized_info.get("requested_downloads")
if isinstance(requested_downloads, list):
for item in requested_downloads:
if not isinstance(item, dict):
continue
filepath = item.get("filepath")
if isinstance(filepath, str):
candidate = Path(filepath)
if candidate.exists():
return candidate
for key in ("filepath", "_filename"):
filepath = normalized_info.get(key)
if isinstance(filepath, str):
candidate = Path(filepath)
if candidate.exists():
return candidate
video_id = normalized_info.get("id")
if not isinstance(video_id, str):
return None
matches = sorted(
(path for path in output_dir.iterdir() if path.is_file() and f"[{video_id}]" in path.name),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
if matches:
return matches[0]
return None
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
type JsRuntimeOptions = dict[str, dict[str, str]]
_MIN_NODE_VERSION = (22, 0, 0)
def find_supported_js_runtimes() -> JsRuntimeOptions | None:
deno_path = which("deno")
if deno_path is not None:
return {"deno": {"path": deno_path}}
node_candidates: dict[str, tuple[int, int, int]] = {}
node_on_path = which("node")
if node_on_path is not None:
version = _probe_executable_version(node_on_path)
if version is not None:
node_candidates[str(Path(node_on_path).resolve())] = version
nvm_versions_dir = Path.home() / ".nvm" / "versions" / "node"
if nvm_versions_dir.exists():
for candidate in nvm_versions_dir.glob("v*/bin/node"):
version = _probe_executable_version(str(candidate))
if version is not None:
node_candidates[str(candidate.resolve())] = version
supported_nodes = {
path: version for path, version in node_candidates.items() if version >= _MIN_NODE_VERSION
}
if not supported_nodes:
return None
best_node_path = max(supported_nodes, key=lambda path: supported_nodes[path])
return {"node": {"path": best_node_path}}
def _probe_executable_version(executable: str) -> tuple[int, int, int] | None:
try:
result = subprocess.run(
[executable, "--version"],
capture_output=True,
check=False,
text=True,
timeout=5,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
output = (result.stdout or result.stderr).strip()
if not output:
return None
return _parse_semver(output.splitlines()[0])
def _parse_semver(value: str) -> tuple[int, int, int] | None:
cleaned = value.strip().lstrip("vV")
parts = cleaned.split(".")
numbers: list[int] = []
for part in parts[:3]:
digits = ""
for character in part:
if not character.isdigit():
break
digits += character
if not digits:
return None
numbers.append(int(digits))
while len(numbers) < 3:
numbers.append(0)
return (numbers[0], numbers[1], numbers[2])
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from urllib.parse import urlparse
from ..exceptions import InvalidUrlError
_YOUTUBE_HOST_SUFFIXES = ("youtube.com", "youtu.be")
def validate_youtube_url(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise InvalidUrlError("URL должна начинаться с http:// или https://")
host = parsed.netloc.lower()
if not host:
raise InvalidUrlError("Не удалось определить домен URL")
if not any(host == suffix or host.endswith(f".{suffix}") for suffix in _YOUTUBE_HOST_SUFFIXES):
raise InvalidUrlError("Поддерживаются только ссылки YouTube")
+2 -130
View File
@@ -1,133 +1,5 @@
from __future__ import annotations
from pathlib import Path
from shutil import which
from typing import cast
from .core.downloader import download_video
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError as YtDlpDownloadError
from yt_dlp.utils import UnsupportedError
from .exceptions import InvalidUrlError, JsRuntimeUnavailableError, VideoDownloadError
from .runtime import JsRuntimeOptions, find_supported_js_runtimes
type Metadata = dict[str, object]
type YtDlpOptions = dict[str, object]
_DEFAULT_OUTTMPL = "%(title).200B [%(id)s].%(ext)s"
def download_video(url: str, output_dir: Path, session_path: Path) -> Path:
options = _build_yt_dlp_options(output_dir=output_dir, session_path=session_path)
try:
with YoutubeDL(options) as youtube_downloader:
extracted_info = cast(
object,
youtube_downloader.extract_info(url, download=True),
)
except UnsupportedError as exc:
raise InvalidUrlError(f"yt-dlp не поддерживает эту ссылку: {exc}") from exc
except YtDlpDownloadError as exc:
raise VideoDownloadError(f"Не удалось скачать видео: {exc}") from exc
if not isinstance(extracted_info, dict):
raise VideoDownloadError("yt-dlp вернул неожиданный формат метаданных")
downloaded_file = _locate_downloaded_file(
info=cast(Metadata, extracted_info),
output_dir=output_dir,
)
if downloaded_file is None:
raise VideoDownloadError(
"Скачивание завершено, но итоговый путь к файлу определить не удалось"
)
return downloaded_file
def _build_yt_dlp_options(output_dir: Path, session_path: Path) -> YtDlpOptions:
js_runtimes = _get_supported_js_runtimes()
ffmpeg_available = which("ffmpeg") is not None
format_selector = (
"bv*[ext=mp4]+ba[ext=m4a]/bv*+ba/b[ext=mp4]/b"
if ffmpeg_available
else "b[ext=mp4]/best"
)
return {
"cookiefile": str(session_path),
"format": format_selector,
"js_runtimes": js_runtimes,
"merge_output_format": "mp4",
"no_warnings": True,
"noplaylist": True,
"noprogress": True,
"outtmpl": str(output_dir / _DEFAULT_OUTTMPL),
"overwrites": False,
"quiet": True,
}
def _get_supported_js_runtimes() -> JsRuntimeOptions:
js_runtimes = find_supported_js_runtimes()
if js_runtimes is None:
raise JsRuntimeUnavailableError(
"Не найден поддерживаемый JavaScript runtime. "
"Установите deno или Node.js 22+."
)
return js_runtimes
def _unwrap_info(info: Metadata) -> Metadata:
if info.get("_type") != "playlist":
return info
entries = info.get("entries")
if not isinstance(entries, list):
return info
for entry in entries:
if isinstance(entry, dict):
return cast(Metadata, entry)
return info
def _locate_downloaded_file(info: Metadata, output_dir: Path) -> Path | None:
normalized_info = _unwrap_info(info)
requested_downloads = normalized_info.get("requested_downloads")
if isinstance(requested_downloads, list):
for item in requested_downloads:
if not isinstance(item, dict):
continue
filepath = item.get("filepath")
if isinstance(filepath, str):
candidate = Path(filepath)
if candidate.exists():
return candidate
for key in ("filepath", "_filename"):
filepath = normalized_info.get(key)
if isinstance(filepath, str):
candidate = Path(filepath)
if candidate.exists():
return candidate
video_id = normalized_info.get("id")
if not isinstance(video_id, str):
return None
matches = sorted(
(
path
for path in output_dir.iterdir()
if path.is_file() and f"[{video_id}]" in path.name
),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
if matches:
return matches[0]
return None
__all__ = ["download_video"]
-5
View File
@@ -1,5 +0,0 @@
class YtShortsDownloaderError(Exception): ...
class InvalidUrlError(YtShortsDownloaderError): ...
class InvalidSessionError(YtShortsDownloaderError): ...
class JsRuntimeUnavailableError(YtShortsDownloaderError): ...
class VideoDownloadError(YtShortsDownloaderError): ...
-19
View File
@@ -1,19 +0,0 @@
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SessionCookie:
domain: str
include_subdomains: bool
path: str
secure: bool
expires: int
name: str
value: str
@dataclass(frozen=True, slots=True)
class SessionValidation:
exists: bool
structurally_valid: bool
fresh: bool
is_usable: bool
message: str
@@ -0,0 +1,6 @@
from __future__ import annotations
from .download import DownloadedVideo
from .session import SessionCookie, SessionValidation
__all__ = ["DownloadedVideo", "SessionCookie", "SessionValidation"]
@@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DownloadedVideo:
filename: str
content: bytes
media_type: str = "video/mp4"
+2 -77
View File
@@ -1,80 +1,5 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
from .core.runtime import JsRuntimeOptions, _parse_semver, find_supported_js_runtimes
type JsRuntimeOptions = dict[str, dict[str, str]]
_MIN_NODE_VERSION = (22, 0, 0)
def find_supported_js_runtimes() -> JsRuntimeOptions | None:
deno_path = which("deno")
if deno_path is not None:
return {"deno": {"path": deno_path}}
node_candidates: dict[str, tuple[int, int, int]] = {}
node_on_path = which("node")
if node_on_path is not None:
version = _probe_executable_version(node_on_path)
if version is not None:
node_candidates[str(Path(node_on_path).resolve())] = version
nvm_versions_dir = Path.home() / ".nvm" / "versions" / "node"
if nvm_versions_dir.exists():
for candidate in nvm_versions_dir.glob("v*/bin/node"):
version = _probe_executable_version(str(candidate))
if version is not None:
node_candidates[str(candidate.resolve())] = version
supported_nodes = {
path: version
for path, version in node_candidates.items()
if version >= _MIN_NODE_VERSION
}
if not supported_nodes:
return None
best_node_path = max(supported_nodes, key=lambda path: supported_nodes[path])
return {"node": {"path": best_node_path}}
def _probe_executable_version(executable: str) -> tuple[int, int, int] | None:
try:
result = subprocess.run(
[executable, "--version"],
capture_output=True,
check=False,
text=True,
timeout=5,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
output = (result.stdout or result.stderr).strip()
if not output:
return None
return _parse_semver(output.splitlines()[0])
def _parse_semver(value: str) -> tuple[int, int, int] | None:
cleaned = value.strip().lstrip("vV")
parts = cleaned.split(".")
numbers: list[int] = []
for part in parts[:3]:
digits = ""
for character in part:
if not character.isdigit():
break
digits += character
if not digits:
return None
numbers.append(int(digits))
while len(numbers) < 3:
numbers.append(0)
return (numbers[0], numbers[1], numbers[2])
__all__ = ["JsRuntimeOptions", "_parse_semver", "find_supported_js_runtimes"]
@@ -0,0 +1,5 @@
from __future__ import annotations
from .session import validate_session_file
__all__ = ["validate_session_file"]
@@ -0,0 +1,181 @@
from __future__ import annotations
import time
from pathlib import Path
from ..models import SessionCookie, SessionValidation
_SESSION_DOMAIN_SUFFIXES = ("youtube.com", "google.com")
_AUTH_COOKIE_NAMES = {
"SID",
"HSID",
"SSID",
"APISID",
"SAPISID",
"__Secure-1PSID",
"__Secure-3PSID",
"LOGIN_INFO",
}
_AUTH_COOKIE_ANCHORS = {
"SID",
"SAPISID",
"__Secure-1PSID",
"__Secure-3PSID",
"LOGIN_INFO",
}
def validate_session_file(path: Path) -> SessionValidation:
normalized_path = path.expanduser().resolve()
if not normalized_path.exists():
return SessionValidation(
exists=False,
structurally_valid=False,
fresh=False,
is_usable=False,
message=f"Файл сессии не найден: {normalized_path}",
)
try:
cookies = _parse_session_file(normalized_path)
except ValueError as exc:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message=f"Файл сессии не прошёл проверку формата: {exc}",
)
if not cookies:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message="Файл сессии пустой",
)
relevant_cookies = [
cookie for cookie in cookies if _matches_domain(cookie.domain, _SESSION_DOMAIN_SUFFIXES)
]
if not relevant_cookies:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message="В файле сессии нет записей для YouTube или Google",
)
now = int(time.time())
fresh_auth_names: set[str] = set()
soonest_expiry: int | None = None
for cookie in relevant_cookies:
if cookie.name not in _AUTH_COOKIE_NAMES:
continue
if cookie.expires != 0 and cookie.expires <= now:
continue
fresh_auth_names.add(cookie.name)
if cookie.expires > 0 and (soonest_expiry is None or cookie.expires < soonest_expiry):
soonest_expiry = cookie.expires
anchor_count = len(fresh_auth_names & _AUTH_COOKIE_ANCHORS)
fresh = len(fresh_auth_names) >= 3 and anchor_count >= 1
if not fresh:
return SessionValidation(
exists=True,
structurally_valid=True,
fresh=False,
is_usable=False,
message=(
"Файл сессии в корректном формате, но в нём нет достаточного набора "
"актуальных YouTube auth-cookie"
),
)
message = (
"Файл сессии валиден и выглядит актуальным для YouTube: "
f"найдено {len(fresh_auth_names)} свежих auth-cookie"
)
if soonest_expiry is not None:
hours_left = max((soonest_expiry - now) // 3600, 0)
message += f", ближайшее истечение примерно через {hours_left} ч"
return SessionValidation(
exists=True,
structurally_valid=True,
fresh=True,
is_usable=True,
message=message,
)
def _parse_session_file(path: Path) -> list[SessionCookie]:
cookies: list[SessionCookie] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
stripped = raw_line.strip()
if not stripped:
continue
is_httponly_cookie = stripped.startswith("#HttpOnly_")
if stripped.startswith("#") and not is_httponly_cookie:
continue
if is_httponly_cookie:
stripped = stripped.removeprefix("#HttpOnly_")
parts = stripped.split("\t")
if len(parts) != 7:
raise ValueError(
"Строка "
f"{line_number}: ожидалось 7 колонок Netscape cookie file, "
f"получено {len(parts)}"
)
(
domain,
include_subdomains,
cookie_path,
secure,
expires,
name,
value,
) = parts
try:
expires_at = int(expires)
except ValueError as exc:
raise ValueError(
f"Строка {line_number}: expires должен быть UNIX timestamp"
) from exc
cookies.append(
SessionCookie(
domain=domain,
include_subdomains=include_subdomains.upper() == "TRUE",
path=cookie_path,
secure=secure.upper() == "TRUE",
expires=expires_at,
name=name,
value=value,
)
)
return cookies
def _matches_domain(domain: str, suffixes: tuple[str, ...]) -> bool:
normalized_domain = _normalize_domain(domain)
return any(
normalized_domain == suffix or normalized_domain.endswith(f".{suffix}")
for suffix in suffixes
)
def _normalize_domain(domain: str) -> str:
return domain.removeprefix("#HttpOnly_").lstrip(".").lower()
+2 -182
View File
@@ -1,185 +1,5 @@
from __future__ import annotations
import time
from pathlib import Path
from .services.session import validate_session_file
from .models import SessionCookie, SessionValidation
_SESSION_DOMAIN_SUFFIXES = ("youtube.com", "google.com")
_AUTH_COOKIE_NAMES = {
"SID",
"HSID",
"SSID",
"APISID",
"SAPISID",
"__Secure-1PSID",
"__Secure-3PSID",
"LOGIN_INFO",
}
_AUTH_COOKIE_ANCHORS = {
"SID",
"SAPISID",
"__Secure-1PSID",
"__Secure-3PSID",
"LOGIN_INFO",
}
def validate_session_file(path: Path) -> SessionValidation:
normalized_path = path.expanduser().resolve()
if not normalized_path.exists():
return SessionValidation(
exists=False,
structurally_valid=False,
fresh=False,
is_usable=False,
message=f"Файл сессии не найден: {normalized_path}",
)
try:
cookies = _parse_session_file(normalized_path)
except ValueError as exc:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message=f"Файл сессии не прошёл проверку формата: {exc}",
)
if not cookies:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message="Файл сессии пустой",
)
relevant_cookies = [
cookie
for cookie in cookies
if _matches_domain(cookie.domain, _SESSION_DOMAIN_SUFFIXES)
]
if not relevant_cookies:
return SessionValidation(
exists=True,
structurally_valid=False,
fresh=False,
is_usable=False,
message="В файле сессии нет записей для YouTube или Google",
)
now = int(time.time())
fresh_auth_names: set[str] = set()
soonest_expiry: int | None = None
for cookie in relevant_cookies:
if cookie.name not in _AUTH_COOKIE_NAMES:
continue
if cookie.expires != 0 and cookie.expires <= now:
continue
fresh_auth_names.add(cookie.name)
if cookie.expires > 0 and (
soonest_expiry is None or cookie.expires < soonest_expiry
):
soonest_expiry = cookie.expires
anchor_count = len(fresh_auth_names & _AUTH_COOKIE_ANCHORS)
fresh = len(fresh_auth_names) >= 3 and anchor_count >= 1
if not fresh:
return SessionValidation(
exists=True,
structurally_valid=True,
fresh=False,
is_usable=False,
message=(
"Файл сессии в корректном формате, но в нём нет достаточного набора "
"актуальных YouTube auth-cookie"
),
)
message = (
"Файл сессии валиден и выглядит актуальным для YouTube: "
f"найдено {len(fresh_auth_names)} свежих auth-cookie"
)
if soonest_expiry is not None:
hours_left = max((soonest_expiry - now) // 3600, 0)
message += f", ближайшее истечение примерно через {hours_left} ч"
return SessionValidation(
exists=True,
structurally_valid=True,
fresh=True,
is_usable=True,
message=message,
)
def _parse_session_file(path: Path) -> list[SessionCookie]:
cookies: list[SessionCookie] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
stripped = raw_line.strip()
if not stripped:
continue
is_httponly_cookie = stripped.startswith("#HttpOnly_")
if stripped.startswith("#") and not is_httponly_cookie:
continue
if is_httponly_cookie:
stripped = stripped.removeprefix("#HttpOnly_")
parts = stripped.split("\t")
if len(parts) != 7:
raise ValueError(
"Строка "
f"{line_number}: ожидалось 7 колонок Netscape cookie file, "
f"получено {len(parts)}"
)
(
domain,
include_subdomains,
cookie_path,
secure,
expires,
name,
value,
) = parts
try:
expires_at = int(expires)
except ValueError as exc:
raise ValueError(
f"Строка {line_number}: expires должен быть UNIX timestamp"
) from exc
cookies.append(
SessionCookie(
domain=domain,
include_subdomains=include_subdomains.upper() == "TRUE",
path=cookie_path,
secure=secure.upper() == "TRUE",
expires=expires_at,
name=name,
value=value,
)
)
return cookies
def _matches_domain(domain: str, suffixes: tuple[str, ...]) -> bool:
normalized_domain = _normalize_domain(domain)
return any(
normalized_domain == suffix or normalized_domain.endswith(f".{suffix}")
for suffix in suffixes
)
def _normalize_domain(domain: str) -> str:
return domain.removeprefix("#HttpOnly_").lstrip(".").lower()
__all__ = ["validate_session_file"]