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.
Files changed (110) hide show
  1. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/PKG-INFO +9 -2
  2. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/README.md +7 -1
  3. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/README_KO.md +7 -1
  4. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/pyproject.toml +2 -0
  5. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/_version.py +2 -2
  6. chzzk_python-0.10.0/src/chzzk/cli/commands/auth.py +586 -0
  7. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/uv.lock +26 -0
  8. chzzk_python-0.9.3/src/chzzk/cli/commands/auth.py +0 -171
  9. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.env.example +0 -0
  10. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/build.yml +0 -0
  11. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/ci.yml +0 -0
  12. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.github/workflows/publish.yml +0 -0
  13. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.gitignore +0 -0
  14. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/.python-version +0 -0
  15. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/LICENSE +0 -0
  16. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/chzzk.spec +0 -0
  17. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/docs/unofficial-chat-websocket-protocol.md +0 -0
  18. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/.env.example +0 -0
  19. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/oauth_server.py +0 -0
  20. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/realtime_chat.py +0 -0
  21. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/realtime_chat_async.py +0 -0
  22. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/session_management.py +0 -0
  23. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/unofficial_chat.py +0 -0
  24. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/examples/unofficial_chat_async.py +0 -0
  25. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/main.py +0 -0
  26. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/scripts/build.py +0 -0
  27. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/__init__.py +0 -0
  28. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/__init__.py +0 -0
  29. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/base.py +0 -0
  30. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/category.py +0 -0
  31. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/channel.py +0 -0
  32. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/chat.py +0 -0
  33. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/live.py +0 -0
  34. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/restriction.py +0 -0
  35. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/session.py +0 -0
  36. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/api/user.py +0 -0
  37. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/__init__.py +0 -0
  38. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/models.py +0 -0
  39. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/oauth.py +0 -0
  40. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/auth/token.py +0 -0
  41. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/__init__.py +0 -0
  42. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/__init__.py +0 -0
  43. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/chat.py +0 -0
  44. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/commands/live.py +0 -0
  45. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/config.py +0 -0
  46. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/formatter.py +0 -0
  47. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/logging.py +0 -0
  48. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/main.py +0 -0
  49. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/cli/writers.py +0 -0
  50. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/client.py +0 -0
  51. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/constants.py +0 -0
  52. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/exceptions/__init__.py +0 -0
  53. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/exceptions/errors.py +0 -0
  54. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/__init__.py +0 -0
  55. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/_base.py +0 -0
  56. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/client.py +0 -0
  57. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/http/endpoints.py +0 -0
  58. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/logging.py +0 -0
  59. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/__init__.py +0 -0
  60. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/category.py +0 -0
  61. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/channel.py +0 -0
  62. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/chat.py +0 -0
  63. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/common.py +0 -0
  64. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/live.py +0 -0
  65. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/restriction.py +0 -0
  66. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/session.py +0 -0
  67. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/models/user.py +0 -0
  68. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/py.typed +0 -0
  69. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/realtime/__init__.py +0 -0
  70. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/realtime/client.py +0 -0
  71. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/__init__.py +0 -0
  72. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/__init__.py +0 -0
  73. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/base.py +0 -0
  74. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/chat.py +0 -0
  75. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/live.py +0 -0
  76. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/api/user.py +0 -0
  77. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/auth/__init__.py +0 -0
  78. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/auth/cookie.py +0 -0
  79. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/__init__.py +0 -0
  80. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/client.py +0 -0
  81. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/connection.py +0 -0
  82. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/handler.py +0 -0
  83. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/chat/monitor.py +0 -0
  84. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/client.py +0 -0
  85. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/__init__.py +0 -0
  86. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/_base.py +0 -0
  87. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/client.py +0 -0
  88. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/http/endpoints.py +0 -0
  89. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/__init__.py +0 -0
  90. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/chat.py +0 -0
  91. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/live.py +0 -0
  92. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/reconnect.py +0 -0
  93. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/src/chzzk/unofficial/models/user.py +0 -0
  94. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/__init__.py +0 -0
  95. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/__init__.py +0 -0
  96. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_category.py +0 -0
  97. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_channel.py +0 -0
  98. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_chat.py +0 -0
  99. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_live.py +0 -0
  100. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_restriction.py +0 -0
  101. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_session.py +0 -0
  102. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/api/test_user.py +0 -0
  103. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/auth/__init__.py +0 -0
  104. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/auth/test_oauth.py +0 -0
  105. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/__init__.py +0 -0
  106. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/test_formatter.py +0 -0
  107. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/cli/test_writers.py +0 -0
  108. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/realtime/__init__.py +0 -0
  109. {chzzk_python-0.9.3 → chzzk_python-0.10.0}/tests/realtime/test_client.py +0 -0
  110. {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.9.3
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
- # Save your Naver cookies (interactive)
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
- # Save your Naver cookies (interactive)
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.9.3'
32
- __version_tuple__ = version_tuple = (0, 9, 3)
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
+ )