chzzk-python 0.9.3__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/PKG-INFO +9 -2
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/README.md +7 -1
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/README_KO.md +7 -1
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/pyproject.toml +2 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/_version.py +2 -2
- chzzk_python-0.10.0/src/chzzk/cli/commands/auth.py +586 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/uv.lock +26 -0
- chzzk_python-0.9.3/src/chzzk/cli/commands/auth.py +0 -171
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.env.example +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/build.yml +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/ci.yml +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/publish.yml +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.gitignore +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.python-version +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/LICENSE +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/chzzk.spec +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/docs/unofficial-chat-websocket-protocol.md +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/.env.example +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/oauth_server.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/realtime_chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/realtime_chat_async.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/session_management.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/unofficial_chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/unofficial_chat_async.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/main.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/scripts/build.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/base.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/category.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/channel.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/restriction.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/session.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/user.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/models.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/oauth.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/token.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/config.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/formatter.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/logging.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/main.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/writers.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/constants.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/exceptions/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/exceptions/errors.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/_base.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/endpoints.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/logging.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/category.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/channel.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/common.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/restriction.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/session.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/user.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/py.typed +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/realtime/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/realtime/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/base.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/user.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/auth/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/auth/cookie.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/connection.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/handler.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/monitor.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/_base.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/endpoints.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/reconnect.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/user.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_category.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_channel.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_chat.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_live.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_restriction.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_session.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_user.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/auth/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/auth/test_oauth.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/test_formatter.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/test_writers.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/realtime/__init__.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/realtime/test_client.py +0 -0
- {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chzzk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Unofficial Python SDK for Chzzk (NAVER Live Streaming Platform) API
|
|
5
5
|
Project-URL: Homepage, https://github.com/hypn4/chzzk-python
|
|
6
6
|
Project-URL: Repository, https://github.com/hypn4/chzzk-python
|
|
@@ -30,6 +30,7 @@ Requires-Dist: websocket-client>=1.9.0
|
|
|
30
30
|
Requires-Dist: websockets>=12.0
|
|
31
31
|
Provides-Extra: cli
|
|
32
32
|
Requires-Dist: prompt-toolkit>=3.0.52; extra == 'cli'
|
|
33
|
+
Requires-Dist: qrcode>=8.2; extra == 'cli'
|
|
33
34
|
Requires-Dist: rich>=14.3.1; extra == 'cli'
|
|
34
35
|
Requires-Dist: typer>=0.15.0; extra == 'cli'
|
|
35
36
|
Description-Content-Type: text/markdown
|
|
@@ -382,7 +383,13 @@ A CLI is available for quick access to the unofficial API features.
|
|
|
382
383
|
### Authentication
|
|
383
384
|
|
|
384
385
|
```bash
|
|
385
|
-
#
|
|
386
|
+
# Login via Naver QR code (recommended)
|
|
387
|
+
chzzk auth qr
|
|
388
|
+
|
|
389
|
+
# Login via Naver QR code with custom timeout
|
|
390
|
+
chzzk auth qr --timeout 60
|
|
391
|
+
|
|
392
|
+
# Save your Naver cookies manually (interactive)
|
|
386
393
|
chzzk auth login
|
|
387
394
|
|
|
388
395
|
# Check authentication status
|
|
@@ -346,7 +346,13 @@ A CLI is available for quick access to the unofficial API features.
|
|
|
346
346
|
### Authentication
|
|
347
347
|
|
|
348
348
|
```bash
|
|
349
|
-
#
|
|
349
|
+
# Login via Naver QR code (recommended)
|
|
350
|
+
chzzk auth qr
|
|
351
|
+
|
|
352
|
+
# Login via Naver QR code with custom timeout
|
|
353
|
+
chzzk auth qr --timeout 60
|
|
354
|
+
|
|
355
|
+
# Save your Naver cookies manually (interactive)
|
|
350
356
|
chzzk auth login
|
|
351
357
|
|
|
352
358
|
# Check authentication status
|
|
@@ -346,7 +346,13 @@ except ChatConnectionError as e:
|
|
|
346
346
|
### 인증
|
|
347
347
|
|
|
348
348
|
```bash
|
|
349
|
-
# 네이버
|
|
349
|
+
# 네이버 QR 코드로 로그인 (권장)
|
|
350
|
+
chzzk auth qr
|
|
351
|
+
|
|
352
|
+
# 타임아웃 설정과 함께 QR 코드 로그인
|
|
353
|
+
chzzk auth qr --timeout 60
|
|
354
|
+
|
|
355
|
+
# 네이버 쿠키 수동 저장 (대화형)
|
|
350
356
|
chzzk auth login
|
|
351
357
|
|
|
352
358
|
# 인증 상태 확인
|
|
@@ -38,6 +38,7 @@ cli = [
|
|
|
38
38
|
"typer>=0.15.0",
|
|
39
39
|
"rich>=14.3.1",
|
|
40
40
|
"prompt-toolkit>=3.0.52",
|
|
41
|
+
"qrcode>=8.2",
|
|
41
42
|
]
|
|
42
43
|
|
|
43
44
|
[project.scripts]
|
|
@@ -59,6 +60,7 @@ dev = [
|
|
|
59
60
|
"pytest-asyncio>=1.3.0",
|
|
60
61
|
"pytest-httpx>=0.35.0",
|
|
61
62
|
"python-dotenv>=1.2.1",
|
|
63
|
+
"pyzbar>=0.1.9",
|
|
62
64
|
"ruff>=0.14.13",
|
|
63
65
|
]
|
|
64
66
|
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.10.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 10, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Authentication commands for CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from chzzk.cli.config import ConfigManager
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
# Naver login endpoints
|
|
26
|
+
NAVER_NID_BASE = "https://nid.naver.com"
|
|
27
|
+
NAVER_QR_LOGIN_URL = f"{NAVER_NID_BASE}/nidlogin.login"
|
|
28
|
+
NAVER_SCHEME_CHECK_URL = f"{NAVER_NID_BASE}/login/scheme.check"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_config(ctx: typer.Context) -> ConfigManager:
|
|
32
|
+
"""Get ConfigManager from context."""
|
|
33
|
+
return ctx.obj["config"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prompt_login_fallback(config: ConfigManager) -> tuple[str, str]:
|
|
37
|
+
"""Fallback prompt-based login for non-TTY environments.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config: Configuration manager.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Tuple of (nid_aut, nid_ses) values.
|
|
44
|
+
"""
|
|
45
|
+
nid_aut = typer.prompt("NID_AUT cookie value")
|
|
46
|
+
nid_ses = typer.prompt("NID_SES cookie value")
|
|
47
|
+
return nid_aut, nid_ses
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def login(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
nid_aut: Annotated[
|
|
54
|
+
str | None,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--nid-aut",
|
|
57
|
+
help="NID_AUT cookie value from Naver login",
|
|
58
|
+
),
|
|
59
|
+
] = None,
|
|
60
|
+
nid_ses: Annotated[
|
|
61
|
+
str | None,
|
|
62
|
+
typer.Option(
|
|
63
|
+
"--nid-ses",
|
|
64
|
+
help="NID_SES cookie value from Naver login",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Save Naver authentication cookies.
|
|
69
|
+
|
|
70
|
+
You can get these cookies from your browser after logging into Naver:
|
|
71
|
+
1. Open browser DevTools (F12)
|
|
72
|
+
2. Go to Application > Cookies > naver.com
|
|
73
|
+
3. Find NID_AUT and NID_SES values
|
|
74
|
+
"""
|
|
75
|
+
config = get_config(ctx)
|
|
76
|
+
json_output = ctx.obj.get("json_output", False)
|
|
77
|
+
|
|
78
|
+
# If both values provided via CLI, skip prompts
|
|
79
|
+
if nid_aut and nid_ses:
|
|
80
|
+
config.save_cookies(nid_aut, nid_ses)
|
|
81
|
+
if json_output:
|
|
82
|
+
console.print(json.dumps({"status": "success", "message": "Cookies saved"}))
|
|
83
|
+
else:
|
|
84
|
+
console.print(
|
|
85
|
+
Panel(
|
|
86
|
+
f"Cookies saved to [cyan]{config.config_dir}[/cyan]",
|
|
87
|
+
title="[green]Login successful[/green]",
|
|
88
|
+
border_style="green",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Use simple prompts
|
|
94
|
+
try:
|
|
95
|
+
final_nid_aut, final_nid_ses = _prompt_login_fallback(config)
|
|
96
|
+
except (KeyboardInterrupt, EOFError):
|
|
97
|
+
console.print("\n[yellow]Login cancelled[/yellow]")
|
|
98
|
+
raise typer.Exit(0) from None
|
|
99
|
+
|
|
100
|
+
config.save_cookies(final_nid_aut, final_nid_ses)
|
|
101
|
+
|
|
102
|
+
if json_output:
|
|
103
|
+
console.print(json.dumps({"status": "success", "message": "Cookies saved"}))
|
|
104
|
+
else:
|
|
105
|
+
console.print(
|
|
106
|
+
Panel(
|
|
107
|
+
f"Cookies saved to [cyan]{config.config_dir}[/cyan]",
|
|
108
|
+
title="[green]Login successful[/green]",
|
|
109
|
+
border_style="green",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def status(ctx: typer.Context) -> None:
|
|
116
|
+
"""Check authentication status."""
|
|
117
|
+
config = get_config(ctx)
|
|
118
|
+
nid_aut, nid_ses = config.get_auth_cookies(
|
|
119
|
+
cli_nid_aut=ctx.obj.get("nid_aut"),
|
|
120
|
+
cli_nid_ses=ctx.obj.get("nid_ses"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
has_auth = bool(nid_aut and nid_ses)
|
|
124
|
+
|
|
125
|
+
if ctx.obj.get("json_output"):
|
|
126
|
+
result = {
|
|
127
|
+
"authenticated": has_auth,
|
|
128
|
+
"has_nid_aut": bool(nid_aut),
|
|
129
|
+
"has_nid_ses": bool(nid_ses),
|
|
130
|
+
"has_stored_cookies": config.has_stored_cookies(),
|
|
131
|
+
}
|
|
132
|
+
console.print(json.dumps(result))
|
|
133
|
+
else:
|
|
134
|
+
if has_auth:
|
|
135
|
+
# Mask cookie values for display
|
|
136
|
+
aut_masked = nid_aut[:8] + "..." if nid_aut and len(nid_aut) > 8 else nid_aut
|
|
137
|
+
ses_masked = nid_ses[:8] + "..." if nid_ses and len(nid_ses) > 8 else nid_ses
|
|
138
|
+
|
|
139
|
+
console.print(
|
|
140
|
+
Panel(
|
|
141
|
+
f"[green]Authenticated[/green]\n\n"
|
|
142
|
+
f"NID_AUT: [dim]{aut_masked}[/dim]\n"
|
|
143
|
+
f"NID_SES: [dim]{ses_masked}[/dim]\n\n"
|
|
144
|
+
f"Stored cookies: {'Yes' if config.has_stored_cookies() else 'No'}",
|
|
145
|
+
title="Authentication Status",
|
|
146
|
+
border_style="green",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
console.print(
|
|
151
|
+
Panel(
|
|
152
|
+
"[red]Not authenticated[/red]\n\n"
|
|
153
|
+
"Run [cyan]chzzk auth login[/cyan] to save your cookies.",
|
|
154
|
+
title="Authentication Status",
|
|
155
|
+
border_style="red",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def qr(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
timeout: Annotated[
|
|
164
|
+
int,
|
|
165
|
+
typer.Option(
|
|
166
|
+
"--timeout",
|
|
167
|
+
"-t",
|
|
168
|
+
help="Login wait timeout in seconds",
|
|
169
|
+
),
|
|
170
|
+
] = 180,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Login via Naver QR code.
|
|
173
|
+
|
|
174
|
+
Displays a QR code in the terminal. Scan it with the Naver app
|
|
175
|
+
and select the verification number to complete login.
|
|
176
|
+
"""
|
|
177
|
+
asyncio.run(_qr_login(ctx, timeout))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _get_qr_session(client: httpx.AsyncClient) -> dict:
|
|
181
|
+
"""Get QR code session from Naver login page.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
client: HTTP client with cookies.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Dictionary containing:
|
|
188
|
+
- session: QR code session key
|
|
189
|
+
- secure_value: 2-digit verification number
|
|
190
|
+
- qr_image_base64: Base64 encoded QR image
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If QR session cannot be extracted (network blocked, etc.)
|
|
194
|
+
"""
|
|
195
|
+
# First visit main login page to get initial cookies
|
|
196
|
+
await client.get(NAVER_QR_LOGIN_URL)
|
|
197
|
+
|
|
198
|
+
# Then request QR mode with full parameters (like browser does)
|
|
199
|
+
resp = await client.get(
|
|
200
|
+
NAVER_QR_LOGIN_URL,
|
|
201
|
+
params={
|
|
202
|
+
"mode": "qrcode",
|
|
203
|
+
"url": "https://www.naver.com/",
|
|
204
|
+
"locale": "en_US",
|
|
205
|
+
"svctype": "1",
|
|
206
|
+
},
|
|
207
|
+
headers={"Referer": NAVER_QR_LOGIN_URL},
|
|
208
|
+
follow_redirects=True,
|
|
209
|
+
)
|
|
210
|
+
resp.raise_for_status()
|
|
211
|
+
|
|
212
|
+
html = resp.text
|
|
213
|
+
|
|
214
|
+
# Check for network block message
|
|
215
|
+
if "네트워크 환경이 불안정합니다" in html or ("warning" in html and "warning_title" in html):
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"네이버가 현재 네트워크 접근을 차단했습니다. "
|
|
218
|
+
"한국 네트워크에서 다시 시도하거나, VPN을 사용해주세요."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Extract qrcodesession - try multiple patterns
|
|
222
|
+
session = None
|
|
223
|
+
session_patterns = [
|
|
224
|
+
r'id="qrcodesession"[^>]*value="([^"]+)"',
|
|
225
|
+
r'name="qrcodesession"[^>]*value="([^"]+)"',
|
|
226
|
+
r'value="([^"]+)"[^>]*name="qrcodesession"',
|
|
227
|
+
r'value="([^"]+)"[^>]*id="qrcodesession"',
|
|
228
|
+
]
|
|
229
|
+
for pattern in session_patterns:
|
|
230
|
+
match = re.search(pattern, html)
|
|
231
|
+
if match:
|
|
232
|
+
session = match.group(1)
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if not session:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"QR 세션을 찾을 수 없습니다. 네이버 로그인 페이지 구조가 변경되었을 수 있습니다."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Extract secureValue (2-digit verification number)
|
|
241
|
+
# Format varies by locale:
|
|
242
|
+
# - Korean: <strong class="point">52</strong>
|
|
243
|
+
# - English: <strong class="point" id="secureValue">27</strong>
|
|
244
|
+
secure_value = None
|
|
245
|
+
secure_patterns = [
|
|
246
|
+
r'<strong[^>]*class="point"[^>]*>(\d+)</strong>',
|
|
247
|
+
r'id="secureValue"[^>]*>(\d+)<',
|
|
248
|
+
]
|
|
249
|
+
for pattern in secure_patterns:
|
|
250
|
+
match = re.search(pattern, html, re.IGNORECASE)
|
|
251
|
+
if match:
|
|
252
|
+
secure_value = match.group(1)
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
if not secure_value:
|
|
256
|
+
raise ValueError("확인 숫자를 찾을 수 없습니다.")
|
|
257
|
+
|
|
258
|
+
# Extract QR code Base64 image
|
|
259
|
+
# Format: <img src="data:image/jpeg;base64, ..." class="qr_img">
|
|
260
|
+
qr_image_base64 = None
|
|
261
|
+
qr_image_patterns = [
|
|
262
|
+
r'<img[^>]*src="(data:image/[^;]+;base64,\s*[^"]+)"[^>]*class="qr_img"',
|
|
263
|
+
r'<img[^>]*class="qr_img"[^>]*src="(data:image/[^;]+;base64,\s*[^"]+)"',
|
|
264
|
+
r'id="qrImage"[^>]*src="(data:image/[^;]+;base64,\s*[^"]+)"',
|
|
265
|
+
r'src="(data:image/[^;]+;base64,\s*[^"]+)"[^>]*id="qrImage"',
|
|
266
|
+
]
|
|
267
|
+
for pattern in qr_image_patterns:
|
|
268
|
+
match = re.search(pattern, html)
|
|
269
|
+
if match:
|
|
270
|
+
qr_image_base64 = match.group(1)
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
if not qr_image_base64:
|
|
274
|
+
raise ValueError("QR 이미지를 찾을 수 없습니다.")
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"session": session,
|
|
278
|
+
"secure_value": secure_value,
|
|
279
|
+
"qr_image_base64": qr_image_base64,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _generate_qr_ascii(session: str) -> str:
|
|
284
|
+
"""Generate ASCII QR code using qrcode library.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
session: QR code session key.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
ASCII representation of QR code.
|
|
291
|
+
"""
|
|
292
|
+
import io
|
|
293
|
+
|
|
294
|
+
import qrcode # type: ignore[import-untyped]
|
|
295
|
+
|
|
296
|
+
qr_url = f"https://nid.naver.com/nidlogin.qrcode?mode=qrcode&qrcodesession={session}"
|
|
297
|
+
|
|
298
|
+
qr = qrcode.QRCode(
|
|
299
|
+
version=1,
|
|
300
|
+
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
|
301
|
+
box_size=1,
|
|
302
|
+
border=1,
|
|
303
|
+
)
|
|
304
|
+
qr.add_data(qr_url)
|
|
305
|
+
qr.make(fit=True)
|
|
306
|
+
|
|
307
|
+
output = io.StringIO()
|
|
308
|
+
qr.print_ascii(out=output, invert=True)
|
|
309
|
+
return output.getvalue()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _poll_login_status(
|
|
313
|
+
client: httpx.AsyncClient,
|
|
314
|
+
session: str,
|
|
315
|
+
timeout: int,
|
|
316
|
+
on_update: Callable[[int], None],
|
|
317
|
+
) -> bool:
|
|
318
|
+
"""Poll for login completion.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
client: HTTP client with cookies.
|
|
322
|
+
session: QR code session key.
|
|
323
|
+
timeout: Maximum wait time in seconds.
|
|
324
|
+
on_update: Callback with remaining time.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True if login succeeded, False if timed out.
|
|
328
|
+
"""
|
|
329
|
+
start = time.time()
|
|
330
|
+
poll_interval = 2
|
|
331
|
+
|
|
332
|
+
while True:
|
|
333
|
+
elapsed = time.time() - start
|
|
334
|
+
remaining = timeout - elapsed
|
|
335
|
+
|
|
336
|
+
if remaining <= 0:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
on_update(int(remaining))
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
resp = await client.get(
|
|
343
|
+
NAVER_SCHEME_CHECK_URL,
|
|
344
|
+
params={"session": session, "cnt": "once"},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Check various success indicators
|
|
348
|
+
if resp.status_code == 200:
|
|
349
|
+
text = resp.text
|
|
350
|
+
# Success when response indicates login complete
|
|
351
|
+
# API returns {"auth_result":"success"} on successful scan
|
|
352
|
+
if '"auth_result":"success"' in text:
|
|
353
|
+
return True
|
|
354
|
+
except httpx.RequestError:
|
|
355
|
+
pass # Continue polling on network errors
|
|
356
|
+
|
|
357
|
+
await asyncio.sleep(poll_interval)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _complete_login(client: httpx.AsyncClient, session: str) -> dict[str, str]:
|
|
361
|
+
"""Complete login and extract cookies.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
client: HTTP client with cookies.
|
|
365
|
+
session: QR code session key.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Dictionary with NID_AUT and NID_SES cookies.
|
|
369
|
+
"""
|
|
370
|
+
resp = await client.post(
|
|
371
|
+
NAVER_QR_LOGIN_URL,
|
|
372
|
+
data={
|
|
373
|
+
"mode": "qrcode",
|
|
374
|
+
"qrcodesession": session,
|
|
375
|
+
"next_step": "false",
|
|
376
|
+
},
|
|
377
|
+
follow_redirects=True,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Extract cookies from response and client jar
|
|
381
|
+
nid_aut = None
|
|
382
|
+
nid_ses = None
|
|
383
|
+
|
|
384
|
+
# Check response cookies
|
|
385
|
+
for cookie in resp.cookies.jar:
|
|
386
|
+
if cookie.name == "NID_AUT":
|
|
387
|
+
nid_aut = cookie.value
|
|
388
|
+
elif cookie.name == "NID_SES":
|
|
389
|
+
nid_ses = cookie.value
|
|
390
|
+
|
|
391
|
+
# Also check client cookie jar
|
|
392
|
+
for cookie in client.cookies.jar:
|
|
393
|
+
if cookie.name == "NID_AUT" and not nid_aut:
|
|
394
|
+
nid_aut = cookie.value
|
|
395
|
+
elif cookie.name == "NID_SES" and not nid_ses:
|
|
396
|
+
nid_ses = cookie.value
|
|
397
|
+
|
|
398
|
+
if not nid_aut or not nid_ses:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"Failed to extract cookies. Got NID_AUT={bool(nid_aut)}, NID_SES={bool(nid_ses)}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return {"NID_AUT": nid_aut, "NID_SES": nid_ses}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def _qr_login(ctx: typer.Context, timeout: int) -> None:
|
|
407
|
+
"""Perform QR code login flow.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
ctx: Typer context.
|
|
411
|
+
timeout: Maximum wait time in seconds.
|
|
412
|
+
"""
|
|
413
|
+
config = get_config(ctx)
|
|
414
|
+
json_output = ctx.obj.get("json_output", False)
|
|
415
|
+
|
|
416
|
+
if not json_output:
|
|
417
|
+
console.print("\n[bold]네이버 QR 코드 로그인[/bold]")
|
|
418
|
+
console.print("━" * 30 + "\n")
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
async with httpx.AsyncClient(
|
|
422
|
+
headers={
|
|
423
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
424
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
425
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
|
|
426
|
+
"image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
427
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
428
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
429
|
+
"Sec-Fetch-Dest": "document",
|
|
430
|
+
"Sec-Fetch-Mode": "navigate",
|
|
431
|
+
"Sec-Fetch-Site": "same-origin",
|
|
432
|
+
"Sec-Fetch-User": "?1",
|
|
433
|
+
"Upgrade-Insecure-Requests": "1",
|
|
434
|
+
},
|
|
435
|
+
follow_redirects=True,
|
|
436
|
+
) as client:
|
|
437
|
+
# Step 1: Get QR session
|
|
438
|
+
if not json_output:
|
|
439
|
+
console.print("[dim]QR 코드 세션 생성 중...[/dim]")
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
qr_data = await _get_qr_session(client)
|
|
443
|
+
except ValueError as e:
|
|
444
|
+
if json_output:
|
|
445
|
+
console.print(json.dumps({"status": "error", "message": str(e)}))
|
|
446
|
+
else:
|
|
447
|
+
console.print(f"[red]오류: {e}[/red]")
|
|
448
|
+
raise typer.Exit(1) from None
|
|
449
|
+
|
|
450
|
+
# Step 2: Display QR code
|
|
451
|
+
qr_ascii = _generate_qr_ascii(qr_data["session"])
|
|
452
|
+
|
|
453
|
+
if json_output:
|
|
454
|
+
console.print(
|
|
455
|
+
json.dumps(
|
|
456
|
+
{
|
|
457
|
+
"status": "waiting",
|
|
458
|
+
"qr_image_base64": qr_data["qr_image_base64"],
|
|
459
|
+
"secure_value": qr_data["secure_value"],
|
|
460
|
+
"message": "Scan QR code with Naver app",
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
console.print(qr_ascii)
|
|
466
|
+
console.print(f"\n[bold yellow]확인 숫자: {qr_data['secure_value']}[/bold yellow]")
|
|
467
|
+
console.print("\n네이버 앱으로 QR 코드를 스캔한 후")
|
|
468
|
+
console.print("위 숫자를 선택하세요.\n")
|
|
469
|
+
|
|
470
|
+
# Step 3: Poll for login completion
|
|
471
|
+
remaining_time = [timeout]
|
|
472
|
+
|
|
473
|
+
def update_remaining(t: int) -> None:
|
|
474
|
+
remaining_time[0] = t
|
|
475
|
+
|
|
476
|
+
if not json_output:
|
|
477
|
+
with Live(console=console, refresh_per_second=1) as live:
|
|
478
|
+
|
|
479
|
+
async def poll_with_display() -> bool:
|
|
480
|
+
start = time.time()
|
|
481
|
+
poll_interval = 2
|
|
482
|
+
|
|
483
|
+
while True:
|
|
484
|
+
elapsed = time.time() - start
|
|
485
|
+
remaining = timeout - elapsed
|
|
486
|
+
|
|
487
|
+
if remaining <= 0:
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
minutes = int(remaining) // 60
|
|
491
|
+
seconds = int(remaining) % 60
|
|
492
|
+
live.update(
|
|
493
|
+
Text(f"대기 중... ({minutes:02d}:{seconds:02d} 남음)", style="dim")
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
resp = await client.get(
|
|
498
|
+
NAVER_SCHEME_CHECK_URL,
|
|
499
|
+
params={"session": qr_data["session"], "cnt": "once"},
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if resp.status_code == 200:
|
|
503
|
+
text = resp.text
|
|
504
|
+
# API returns {"auth_result":"success"} on successful scan
|
|
505
|
+
if '"auth_result":"success"' in text:
|
|
506
|
+
return True
|
|
507
|
+
except httpx.RequestError:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
await asyncio.sleep(poll_interval)
|
|
511
|
+
|
|
512
|
+
login_success = await poll_with_display()
|
|
513
|
+
else:
|
|
514
|
+
login_success = await _poll_login_status(
|
|
515
|
+
client, qr_data["session"], timeout, update_remaining
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not login_success:
|
|
519
|
+
if json_output:
|
|
520
|
+
console.print(json.dumps({"status": "error", "message": "Login timeout"}))
|
|
521
|
+
else:
|
|
522
|
+
console.print("\n[red]타임아웃: 로그인 시간이 초과되었습니다.[/red]")
|
|
523
|
+
console.print("[dim]다시 시도하려면 chzzk auth qr를 실행하세요.[/dim]")
|
|
524
|
+
raise typer.Exit(1)
|
|
525
|
+
|
|
526
|
+
# Step 4: Complete login and extract cookies
|
|
527
|
+
try:
|
|
528
|
+
cookies = await _complete_login(client, qr_data["session"])
|
|
529
|
+
except ValueError as e:
|
|
530
|
+
if json_output:
|
|
531
|
+
console.print(json.dumps({"status": "error", "message": str(e)}))
|
|
532
|
+
else:
|
|
533
|
+
console.print(f"\n[red]쿠키 추출 실패: {e}[/red]")
|
|
534
|
+
raise typer.Exit(1) from None
|
|
535
|
+
|
|
536
|
+
# Step 5: Save cookies
|
|
537
|
+
config.save_cookies(cookies["NID_AUT"], cookies["NID_SES"])
|
|
538
|
+
|
|
539
|
+
if json_output:
|
|
540
|
+
console.print(
|
|
541
|
+
json.dumps(
|
|
542
|
+
{
|
|
543
|
+
"status": "success",
|
|
544
|
+
"message": "Login successful",
|
|
545
|
+
"cookies_saved": str(config.config_dir / "cookies.json"),
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
console.print("\n[green]✓ 로그인 성공![/green]")
|
|
551
|
+
console.print(
|
|
552
|
+
f" 쿠키 저장 완료: [cyan]{config.config_dir / 'cookies.json'}[/cyan]"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
except httpx.RequestError as e:
|
|
556
|
+
if json_output:
|
|
557
|
+
console.print(json.dumps({"status": "error", "message": f"Network error: {e}"}))
|
|
558
|
+
else:
|
|
559
|
+
console.print(f"[red]네트워크 오류: {e}[/red]")
|
|
560
|
+
raise typer.Exit(1) from None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@app.command()
|
|
564
|
+
def logout(ctx: typer.Context) -> None:
|
|
565
|
+
"""Delete stored authentication cookies."""
|
|
566
|
+
config = get_config(ctx)
|
|
567
|
+
|
|
568
|
+
if not config.has_stored_cookies():
|
|
569
|
+
if ctx.obj.get("json_output"):
|
|
570
|
+
console.print(json.dumps({"status": "info", "message": "No cookies stored"}))
|
|
571
|
+
else:
|
|
572
|
+
console.print("[yellow]No stored cookies to delete.[/yellow]")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
config.delete_cookies()
|
|
576
|
+
|
|
577
|
+
if ctx.obj.get("json_output"):
|
|
578
|
+
console.print(json.dumps({"status": "success", "message": "Cookies deleted"}))
|
|
579
|
+
else:
|
|
580
|
+
console.print(
|
|
581
|
+
Panel(
|
|
582
|
+
"Stored cookies have been deleted.",
|
|
583
|
+
title="[green]Logout successful[/green]",
|
|
584
|
+
border_style="green",
|
|
585
|
+
)
|
|
586
|
+
)
|