tzzb 0.2.0__py3-none-any.whl

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.
tzzb/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """tzzb — Python SDK, CLI, and MCP Server for Tonghuashun Investment Ledger (同花顺投资账本)."""
2
+
3
+ from .client import TzzbClient, TzzbError
4
+ from .config import TzzbConfig, config
5
+ from .models import Account, AssetTrendPoint, LoginStatus, Position, TradeRecord
6
+
7
+ __all__ = [
8
+ "TzzbClient",
9
+ "TzzbError",
10
+ "TzzbConfig",
11
+ "config",
12
+ "Account",
13
+ "AssetTrendPoint",
14
+ "LoginStatus",
15
+ "Position",
16
+ "TradeRecord",
17
+ ]
18
+ __version__ = "0.1.0"
tzzb/auth.py ADDED
@@ -0,0 +1,474 @@
1
+ """Authentication and Chrome DevTools Protocol (CDP) bridge for tzzb.
2
+
3
+ Provides session management, Chrome process management, and CDP request execution
4
+ to bypass Tonghuashun dynamic anti-bot signatures (hexin-v).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import socket
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import websocket
19
+
20
+ from .models import LoginStatus
21
+
22
+ COOKIE_FILE = Path.home() / ".tzzb_cookies.json"
23
+ REQUIRED_COOKIES = {"userid", "ticket", "user"}
24
+ TZZB_URL = "https://tzzb.10jqka.com.cn/pc/index.html#/unlogin"
25
+ CDP_PORT = 9222
26
+ CHROME_PROFILE = Path.home() / ".tzzb_chrome_profile"
27
+
28
+ _ws: websocket.WebSocket | None = None
29
+ _msg_id: int = 0
30
+ _cdp_lock = threading.Lock()
31
+
32
+
33
+ def check_port(port: int = CDP_PORT) -> bool:
34
+ """Check if the given port is open."""
35
+ try:
36
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
37
+ sock.settimeout(1)
38
+ result = sock.connect_ex(("127.0.0.1", port))
39
+ sock.close()
40
+ return result == 0
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ def find_chrome() -> str | None:
46
+ """Find system Chrome/Chromium executable."""
47
+ candidates = [
48
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
49
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
50
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
51
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
52
+ str(Path.home() / "AppData/Local/Google/Chrome/Application/chrome.exe"),
53
+ "/usr/bin/google-chrome",
54
+ "/usr/bin/chromium",
55
+ "/usr/bin/chromium-browser",
56
+ ]
57
+ for path in candidates:
58
+ if Path(path).exists():
59
+ return path
60
+
61
+ for cmd in [["which", "google-chrome"], ["which", "chromium"], ["which", "chrome"], ["where", "chrome"]]:
62
+ try:
63
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
64
+ if result.returncode == 0 and result.stdout.strip():
65
+ return result.stdout.strip().split("\n")[0]
66
+ except Exception:
67
+ pass
68
+ return None
69
+
70
+
71
+ def launch_chrome() -> bool:
72
+ """Launch isolated Chrome debug instance with visible GUI window."""
73
+ import sys
74
+ chrome_path = find_chrome()
75
+ if not chrome_path:
76
+ return False
77
+
78
+ CHROME_PROFILE.mkdir(parents=True, exist_ok=True)
79
+
80
+ if sys.platform == "darwin":
81
+ subprocess.Popen(
82
+ [
83
+ "open",
84
+ "-n",
85
+ "-a",
86
+ "Google Chrome",
87
+ "--args",
88
+ f"--remote-debugging-port={CDP_PORT}",
89
+ f"--user-data-dir={CHROME_PROFILE}",
90
+ "--remote-allow-origins=*",
91
+ "--no-first-run",
92
+ "--no-default-browser-check",
93
+ TZZB_URL,
94
+ ]
95
+ )
96
+ else:
97
+ creationflags = 0
98
+ if hasattr(subprocess, "DETACHED_PROCESS"):
99
+ creationflags |= subprocess.DETACHED_PROCESS
100
+ if hasattr(subprocess, "CREATE_NO_WINDOW"):
101
+ creationflags |= subprocess.CREATE_NO_WINDOW
102
+
103
+ subprocess.Popen(
104
+ [
105
+ chrome_path,
106
+ f"--remote-debugging-port={CDP_PORT}",
107
+ f"--user-data-dir={CHROME_PROFILE}",
108
+ "--remote-allow-origins=*",
109
+ "--no-first-run",
110
+ "--no-default-browser-check",
111
+ TZZB_URL,
112
+ ],
113
+ stdout=subprocess.DEVNULL,
114
+ stderr=subprocess.DEVNULL,
115
+ creationflags=creationflags,
116
+ )
117
+
118
+ for _ in range(30):
119
+ time.sleep(1)
120
+ if check_port(CDP_PORT):
121
+ return True
122
+ return False
123
+
124
+
125
+ def reset_connection() -> None:
126
+ """Close existing WebSocket connection and reset state."""
127
+ global _ws
128
+ if _ws:
129
+ try:
130
+ _ws.close()
131
+ except Exception:
132
+ pass
133
+ _ws = None
134
+
135
+
136
+ def get_page_ws_url() -> str:
137
+ """Retrieve debugger WebSocket URL for an active page target."""
138
+ try:
139
+ resp = urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json")
140
+ targets = json.loads(resp.read())
141
+ except Exception:
142
+ targets = []
143
+
144
+ for t in targets:
145
+ if t.get("type") == "page":
146
+ return t["webSocketDebuggerUrl"]
147
+
148
+ # Try creating a new target page if none exist
149
+ try:
150
+ req = urllib.request.Request(f"http://127.0.0.1:{CDP_PORT}/json/new?{TZZB_URL}", method="PUT")
151
+ resp = urllib.request.urlopen(req)
152
+ new_target = json.loads(resp.read())
153
+ if "webSocketDebuggerUrl" in new_target:
154
+ return new_target["webSocketDebuggerUrl"]
155
+ except Exception:
156
+ pass
157
+
158
+ raise RuntimeError("No available page targets in Chrome CDP")
159
+
160
+
161
+ def wait_for_page_load(timeout: int = 15) -> None:
162
+ """Wait until page state is ready for requests."""
163
+ global _msg_id
164
+
165
+ _msg_id += 1
166
+ nav_msg = {
167
+ "id": _msg_id,
168
+ "method": "Page.navigate",
169
+ "params": {"url": "https://tzzb.10jqka.com.cn/pc/index.html"},
170
+ }
171
+ _ws.send(json.dumps(nav_msg))
172
+
173
+ try:
174
+ _ws.settimeout(5)
175
+ while True:
176
+ raw = _ws.recv()
177
+ parsed = json.loads(raw)
178
+ if parsed.get("id") == _msg_id:
179
+ break
180
+ except Exception:
181
+ pass
182
+
183
+ check_expr = "(function(){return document.readyState==='complete'?1:0})()"
184
+ deadline = time.time() + timeout
185
+ while time.time() < deadline:
186
+ _msg_id += 1
187
+ eval_msg = {
188
+ "id": _msg_id,
189
+ "method": "Runtime.evaluate",
190
+ "params": {
191
+ "expression": check_expr,
192
+ "returnByValue": True,
193
+ "awaitPromise": False,
194
+ },
195
+ }
196
+ _ws.send(json.dumps(eval_msg))
197
+
198
+ try:
199
+ _ws.settimeout(3)
200
+ while True:
201
+ raw = _ws.recv()
202
+ parsed = json.loads(raw)
203
+ if parsed.get("id") == _msg_id:
204
+ value = parsed.get("result", {}).get("result", {}).get("value", 0)
205
+ if value == 1:
206
+ time.sleep(1)
207
+ return
208
+ break
209
+ except websocket.WebSocketTimeoutException:
210
+ pass
211
+ time.sleep(0.5)
212
+
213
+ time.sleep(2)
214
+
215
+
216
+ def cdp_connect() -> None:
217
+ """Connect to Chrome CDP target."""
218
+ global _ws, _msg_id
219
+
220
+ reset_connection()
221
+ ws_url = get_page_ws_url()
222
+ _ws = websocket.create_connection(ws_url, timeout=10)
223
+ _msg_id = 0
224
+
225
+ cdp_send("Page.enable")
226
+ wait_for_page_load(timeout=15)
227
+
228
+
229
+ def cdp_send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
230
+ """Send CDP command and wait for response."""
231
+ global _msg_id
232
+
233
+ _msg_id += 1
234
+ msg = {"id": _msg_id, "method": method, "params": params or {}}
235
+ _ws.send(json.dumps(msg))
236
+
237
+ while True:
238
+ raw = _ws.recv()
239
+ parsed = json.loads(raw)
240
+ if parsed.get("id") == _msg_id:
241
+ return parsed
242
+
243
+
244
+ def ensure_connection() -> None:
245
+ """Ensure CDP connection is established and healthy."""
246
+ global _ws
247
+
248
+ if _ws is not None:
249
+ try:
250
+ _ws.ping()
251
+ return
252
+ except Exception:
253
+ reset_connection()
254
+
255
+ if not check_port(CDP_PORT):
256
+ if not launch_chrome():
257
+ raise RuntimeError("Failed to launch or connect to Chrome on CDP port 9222")
258
+
259
+ cdp_connect()
260
+
261
+
262
+ def cdp_send_safe(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
263
+ """Thread-safe CDP call with automatic reconnection."""
264
+ with _cdp_lock:
265
+ ensure_connection()
266
+ try:
267
+ return cdp_send(method, params)
268
+ except (websocket.WebSocketException, ConnectionError, OSError):
269
+ reset_connection()
270
+ ensure_connection()
271
+ try:
272
+ return cdp_send(method, params)
273
+ except Exception:
274
+ reset_connection()
275
+ raise
276
+
277
+
278
+ def cdp_evaluate(expression: str) -> dict[str, Any]:
279
+ """Execute JavaScript in browser via Runtime.evaluate."""
280
+ return cdp_send_safe(
281
+ "Runtime.evaluate",
282
+ {
283
+ "expression": expression,
284
+ "returnByValue": True,
285
+ "awaitPromise": True,
286
+ },
287
+ )
288
+
289
+
290
+ def load_cookies() -> dict[str, str] | None:
291
+ """Load cached cookies from disk."""
292
+ if not COOKIE_FILE.exists():
293
+ return None
294
+ try:
295
+ data = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
296
+ if REQUIRED_COOKIES.issubset(data.keys()):
297
+ return {k: str(data[k]) for k in REQUIRED_COOKIES}
298
+ except Exception:
299
+ pass
300
+ return None
301
+
302
+
303
+ def save_cookies(cookies: dict[str, str]) -> None:
304
+ """Persist cookies to disk."""
305
+ data = {
306
+ **cookies,
307
+ "extracted_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
308
+ "source": "cdp",
309
+ }
310
+ COOKIE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
311
+
312
+
313
+ def clear_cookies() -> None:
314
+ """Clear cached cookies."""
315
+ COOKIE_FILE.unlink(missing_ok=True)
316
+
317
+
318
+ def cdp_get_cookies() -> dict[str, str]:
319
+ """Extract cookies from browser via Network.getCookies."""
320
+ result = cdp_send_safe("Network.getCookies", {"urls": ["https://tzzb.10jqka.com.cn"]})
321
+ cookies = result.get("result", {}).get("cookies", [])
322
+ extracted: dict[str, str] = {}
323
+ for c in cookies:
324
+ if "10jqka.com.cn" in c.get("domain", "") and c["name"] in REQUIRED_COOKIES:
325
+ extracted[c["name"]] = c["value"]
326
+ return extracted if REQUIRED_COOKIES.issubset(extracted.keys()) else {}
327
+
328
+
329
+ def get_login_status() -> LoginStatus:
330
+ """Get current authentication status."""
331
+ cookies = load_cookies()
332
+ if cookies:
333
+ try:
334
+ raw = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
335
+ extracted_at = raw.get("extracted_at", "")
336
+ except Exception:
337
+ extracted_at = ""
338
+ return LoginStatus(
339
+ logged_in=True,
340
+ userid=cookies.get("userid", ""),
341
+ cookie_count=len(cookies),
342
+ extracted_at=extracted_at,
343
+ message="Authenticated",
344
+ )
345
+ return LoginStatus(logged_in=False, message="Not authenticated")
346
+
347
+
348
+ def logout() -> dict[str, Any]:
349
+ """Log out by clearing both file and browser cookies."""
350
+ clear_cookies()
351
+ try:
352
+ if check_port(CDP_PORT):
353
+ cdp_send_safe("Network.clearBrowserCookies")
354
+ cdp_send_safe("Page.navigate", {"url": TZZB_URL})
355
+ except Exception:
356
+ pass
357
+ return {"status": "success", "message": "Logged out successfully"}
358
+
359
+
360
+ def focus_chrome() -> None:
361
+ """Bring Chrome window to the foreground on macOS."""
362
+ import sys
363
+ if sys.platform == "darwin":
364
+ try:
365
+ subprocess.run(
366
+ ["osascript", "-e", 'tell application "Google Chrome" to activate'],
367
+ capture_output=True,
368
+ timeout=2,
369
+ )
370
+ except Exception:
371
+ pass
372
+
373
+
374
+ def ensure_qr_login_active(timeout: float = 15.0) -> bool:
375
+ """Ensure the login dialog is open and switched to QR code scan mode."""
376
+ deadline = time.time() + timeout
377
+ while time.time() < deadline:
378
+ # 1. Trigger modal on main page if not open
379
+ trigger_modal_js = """
380
+ (() => {
381
+ const popup = document.getElementById('ths_iframe_login_popup');
382
+ if (!popup || popup.style.display === 'none') {
383
+ const btn = document.querySelector('.UnloginBar_login') || document.querySelector('.Unlogin_top_box_btn');
384
+ if (btn) btn.click();
385
+ return true;
386
+ }
387
+ return false;
388
+ })()
389
+ """
390
+ try:
391
+ cdp_evaluate(trigger_modal_js)
392
+ except Exception:
393
+ pass
394
+
395
+ # 2. Check child iframe and ensure qrcode_pannel is active
396
+ try:
397
+ ft = cdp_send_safe("Page.getFrameTree")
398
+ child_frames = ft.get("result", {}).get("frameTree", {}).get("childFrames", [])
399
+ for cf in child_frames:
400
+ frame_id = cf.get("frame", {}).get("id")
401
+ if not frame_id:
402
+ continue
403
+ world = cdp_send_safe("Page.createIsolatedWorld", {"frameId": frame_id, "worldName": "tzzb_login"})
404
+ ctx_id = world.get("result", {}).get("executionContextId")
405
+ if ctx_id:
406
+ res = cdp_send_safe(
407
+ "Runtime.evaluate",
408
+ {
409
+ "contextId": ctx_id,
410
+ "expression": """
411
+ (() => {
412
+ const qrPannel = document.getElementById('qrcode_pannel');
413
+ if (qrPannel && window.getComputedStyle(qrPannel).display === 'block') {
414
+ return 'ready';
415
+ }
416
+ const toQr = document.getElementById('to_qrcode_login');
417
+ if (toQr) {
418
+ toQr.click();
419
+ return 'clicked';
420
+ }
421
+ return 'waiting';
422
+ })()
423
+ """,
424
+ "returnByValue": True,
425
+ },
426
+ )
427
+ status = res.get("result", {}).get("result", {}).get("value")
428
+ if status == "ready":
429
+ return True
430
+ except Exception:
431
+ pass
432
+ time.sleep(0.5)
433
+ return False
434
+
435
+
436
+ def login(timeout: int = 120, force: bool = False) -> dict[str, Any]:
437
+ """Interactive login flow via Chrome CDP."""
438
+ try:
439
+ if not check_port(CDP_PORT):
440
+ if not launch_chrome():
441
+ return {"status": "error", "message": "Failed to launch Chrome. Please launch Chrome manually."}
442
+
443
+ if force:
444
+ logout()
445
+
446
+ existing = cdp_get_cookies()
447
+ if existing and not force:
448
+ save_cookies(existing)
449
+ return {
450
+ "status": "success",
451
+ "message": f"Cookie valid, userid={existing['userid'][:6]}***",
452
+ "cookies": {k: v[:6] + "***" for k, v in existing.items()},
453
+ }
454
+
455
+ cdp_send_safe("Page.navigate", {"url": TZZB_URL})
456
+ focus_chrome()
457
+ time.sleep(1)
458
+ ensure_qr_login_active(timeout=15.0)
459
+
460
+ start_time = time.time()
461
+ while time.time() - start_time < timeout:
462
+ time.sleep(1)
463
+ cookies = cdp_get_cookies()
464
+ if cookies:
465
+ save_cookies(cookies)
466
+ return {
467
+ "status": "success",
468
+ "message": f"Login successful, userid={cookies['userid'][:6]}***",
469
+ "cookies": {k: v[:6] + "***" for k, v in cookies.items()},
470
+ }
471
+
472
+ return {"status": "timeout", "message": f"Login timed out after {timeout} seconds"}
473
+ except Exception as e:
474
+ return {"status": "error", "message": str(e)}