haozhuma 0.1.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.
haozhuma/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from .client import HaozhuClient, from_token, login
2
+ from .exceptions import HaozhuAPIError, HaozhuError, HaozhuResponseError, HaozhuTimeoutError
3
+ from .models import AccountSummary, MessageResult, PhoneResult
4
+ from .provider import SMSProvider
5
+ from .token_cache import TokenCache
6
+
7
+ __all__ = [
8
+ "AccountSummary",
9
+ "HaozhuAPIError",
10
+ "HaozhuClient",
11
+ "HaozhuError",
12
+ "HaozhuResponseError",
13
+ "HaozhuTimeoutError",
14
+ "MessageResult",
15
+ "PhoneResult",
16
+ "SMSProvider",
17
+ "TokenCache",
18
+ "from_token",
19
+ "login",
20
+ ]
21
+
22
+ __version__ = "0.1.0"
haozhuma/client.py ADDED
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from typing import Any, Callable, Mapping
7
+
8
+ from curl_cffi import requests
9
+
10
+ from .exceptions import HaozhuAPIError, HaozhuResponseError, HaozhuTimeoutError
11
+ from .models import AccountSummary, MessageResult, PhoneResult
12
+ from .token_cache import TokenCache
13
+
14
+ SUCCESS_CODES = {"0", "200"}
15
+ DEFAULT_SERVER = "api.haozhuma.com"
16
+ DEFAULT_AUTHOR = "adminzfz"
17
+ DEFAULT_POLL_INTERVAL = 15
18
+ DEFAULT_WAIT_TIMEOUT = 180
19
+ CODE_PATTERN = re.compile(r"(?<!\d)(\d{4,8})(?!\d)")
20
+
21
+
22
+ class HaozhuClient:
23
+ def __init__(
24
+ self,
25
+ server: str = DEFAULT_SERVER,
26
+ username: str = "",
27
+ password: str = "",
28
+ token: str = "",
29
+ project_id: str = "",
30
+ author: str = DEFAULT_AUTHOR,
31
+ timeout: int = 10,
32
+ poll_interval: int = DEFAULT_POLL_INTERVAL,
33
+ wait_timeout: int = DEFAULT_WAIT_TIMEOUT,
34
+ impersonate: str = "chrome120",
35
+ session: Any | None = None,
36
+ ):
37
+ self.base_url = self.normalize_server(server)
38
+ self.username = str(username or "").strip()
39
+ self.password = str(password or "").strip()
40
+ self.project_id = str(project_id or "").strip()
41
+ self.author = str(author or DEFAULT_AUTHOR).strip()
42
+ self.timeout = int(timeout)
43
+ self.poll_interval = int(poll_interval)
44
+ self.wait_timeout = int(wait_timeout)
45
+ self.impersonate = str(impersonate or "").strip() or None
46
+ self.session = session or requests.Session()
47
+ self._token = str(token or "").strip()
48
+ self.last_phone: PhoneResult | None = None
49
+ self.last_message: MessageResult | None = None
50
+
51
+ @classmethod
52
+ def from_env(cls, prefix: str = "HAOZHUMA_") -> "HaozhuClient":
53
+ def env(name: str, default: str = "") -> str:
54
+ return os.getenv(f"{prefix}{name}", default)
55
+
56
+ return cls(
57
+ server=env("SERVER", DEFAULT_SERVER),
58
+ username=env("USER"),
59
+ password=env("PASSWORD"),
60
+ token=env("TOKEN"),
61
+ project_id=env("SID"),
62
+ author=env("AUTHOR", DEFAULT_AUTHOR),
63
+ poll_interval=int(env("POLL_INTERVAL", str(DEFAULT_POLL_INTERVAL))),
64
+ wait_timeout=int(env("WAIT_TIMEOUT", str(DEFAULT_WAIT_TIMEOUT))),
65
+ impersonate=env("IMPERSONATE", "chrome120"),
66
+ )
67
+
68
+ @staticmethod
69
+ def normalize_server(server: str) -> str:
70
+ value = str(server or DEFAULT_SERVER).strip().rstrip("/")
71
+ if value.startswith(("http://", "https://")):
72
+ return value
73
+ return f"https://{value}"
74
+
75
+ @property
76
+ def token(self) -> str:
77
+ return self._token
78
+
79
+ @property
80
+ def phone(self) -> str:
81
+ if not self.last_phone:
82
+ return ""
83
+ return self.last_phone.phone
84
+
85
+ @property
86
+ def phone_info(self) -> PhoneResult | None:
87
+ return self.last_phone
88
+
89
+ @property
90
+ def message(self) -> MessageResult | None:
91
+ return self.last_message
92
+
93
+ @property
94
+ def code(self) -> str:
95
+ if not self.last_message:
96
+ return ""
97
+ return self.last_message.sms_code
98
+
99
+ def _sid(self, sid: str = "") -> str:
100
+ value = str(sid or self.project_id or "").strip()
101
+ if not value:
102
+ raise ValueError("豪猪项目ID为空,请传入 sid 或初始化 project_id")
103
+ return value
104
+
105
+ def _phone(self, phone: str = "") -> str:
106
+ value = str(phone or self.phone or "").strip()
107
+ if not value:
108
+ raise ValueError("当前没有手机号,请先调用 get_phone(),或显式传入 phone")
109
+ return value
110
+
111
+ def _request_kwargs(self, params: Mapping[str, Any]) -> dict[str, Any]:
112
+ kwargs: dict[str, Any] = {
113
+ "params": dict(params),
114
+ "timeout": self.timeout,
115
+ }
116
+ if self.impersonate:
117
+ kwargs["impersonate"] = self.impersonate
118
+ return kwargs
119
+
120
+ def request(self, api: str, allow_wait: bool = False, **params: Any) -> dict[str, Any]:
121
+ query: dict[str, Any] = {"api": api}
122
+ for key, value in params.items():
123
+ if value in (None, ""):
124
+ continue
125
+ query[key] = value
126
+
127
+ response = self.session.get(f"{self.base_url}/sms/", **self._request_kwargs(query))
128
+ response.raise_for_status()
129
+ response.encoding = "utf-8"
130
+
131
+ try:
132
+ data = response.json()
133
+ except Exception as exc:
134
+ raise HaozhuResponseError(f"豪猪接口返回非 JSON: api={api}, text={response.text[:300]}") from exc
135
+
136
+ if not isinstance(data, dict):
137
+ raise HaozhuResponseError(f"豪猪接口返回结构异常: api={api}, payload={data!r}")
138
+
139
+ code = str(data.get("code", "")).strip()
140
+ msg = str(data.get("msg") or "")
141
+ if code in SUCCESS_CODES:
142
+ return data
143
+ if allow_wait and code == "-1" and "等待" in msg:
144
+ return data
145
+ raise HaozhuAPIError(api, data)
146
+
147
+ def login(self, username: str = "", password: str = "", force: bool = False) -> str:
148
+ if self._token and not force:
149
+ return self._token
150
+
151
+ user = str(username or self.username or "").strip()
152
+ passwd = str(password or self.password or "").strip()
153
+ if not user or not passwd:
154
+ raise ValueError("豪猪 API 用户名或密码为空")
155
+
156
+ data = self.request("login", **{"user": user, "pass": passwd})
157
+ token = str(data.get("token") or "").strip()
158
+ if not token:
159
+ raise HaozhuResponseError(f"豪猪登录未返回 token: {data}")
160
+ self._token = token
161
+ return token
162
+
163
+ def ensure_token(self) -> str:
164
+ if self._token:
165
+ return self._token
166
+ return self.login()
167
+
168
+ def get_summary(self) -> AccountSummary:
169
+ data = self.request("getSummary", token=self.ensure_token())
170
+ return AccountSummary.from_payload(data)
171
+
172
+ def get_phone_raw(
173
+ self,
174
+ sid: str = "",
175
+ phone: str = "",
176
+ isp: str | int = "",
177
+ province: str | int = "",
178
+ ascription: str | int = "",
179
+ paragraph: str = "",
180
+ exclude: str = "",
181
+ uid: str = "",
182
+ author: str = "",
183
+ ) -> dict[str, Any]:
184
+ return self.request(
185
+ "getPhone",
186
+ token=self.ensure_token(),
187
+ sid=self._sid(sid),
188
+ phone=str(phone or ""),
189
+ isp=isp,
190
+ Province=province,
191
+ ascription=ascription,
192
+ paragraph=paragraph,
193
+ exclude=exclude,
194
+ uid=uid,
195
+ author=author or self.author,
196
+ )
197
+
198
+ def get_phone_info(self, sid: str = "", **filters: Any) -> PhoneResult:
199
+ self.last_phone = PhoneResult.from_payload(self.get_phone_raw(sid=sid, **filters))
200
+ return self.last_phone
201
+
202
+ def get_phone(self, sid: str = "", **filters: Any) -> str:
203
+ return self.get_phone_info(sid=sid, **filters).phone
204
+
205
+ def occupy_phone_info(self, phone: str, sid: str = "", author: str = "") -> PhoneResult:
206
+ self.last_phone = PhoneResult.from_payload(
207
+ self.get_phone_raw(
208
+ sid=sid,
209
+ phone=str(phone),
210
+ author=author or self.author,
211
+ )
212
+ )
213
+ return self.last_phone
214
+
215
+ def occupy_phone(self, phone: str, sid: str = "", author: str = "") -> str:
216
+ return self.occupy_phone_info(phone=phone, sid=sid, author=author).phone
217
+
218
+ def get_message_raw(self, phone: str = "", sid: str = "", allow_wait: bool = False) -> dict[str, Any]:
219
+ phone_value = self._phone(phone)
220
+ return self.request(
221
+ "getMessage",
222
+ allow_wait=allow_wait,
223
+ token=self.ensure_token(),
224
+ sid=self._sid(sid),
225
+ phone=phone_value,
226
+ )
227
+
228
+ def get_message(self, phone: str = "", sid: str = "", allow_wait: bool = False) -> MessageResult:
229
+ sid_value = self._sid(sid)
230
+ phone_value = self._phone(phone)
231
+ payload = self.get_message_raw(phone_value, sid=sid_value, allow_wait=allow_wait)
232
+ self.last_message = MessageResult.from_payload(
233
+ phone=phone_value,
234
+ sid=sid_value,
235
+ payload=payload,
236
+ sms_code=self.extract_code(payload),
237
+ )
238
+ return self.last_message
239
+
240
+ def release_phone(self, phone: str = "", sid: str = "") -> dict[str, Any]:
241
+ return self.request(
242
+ "cancelRecv",
243
+ token=self.ensure_token(),
244
+ sid=self._sid(sid),
245
+ phone=self._phone(phone),
246
+ )
247
+
248
+ def release_all(self) -> dict[str, Any]:
249
+ return self.request("cancelAllRecv", token=self.ensure_token())
250
+
251
+ def blacklist_phone(self, phone: str = "", sid: str = "") -> dict[str, Any]:
252
+ return self.request(
253
+ "addBlacklist",
254
+ token=self.ensure_token(),
255
+ sid=self._sid(sid),
256
+ phone=self._phone(phone),
257
+ )
258
+
259
+ def block_phone(self, phone: str = "", sid: str = "") -> dict[str, Any]:
260
+ return self.blacklist_phone(phone=phone, sid=sid)
261
+
262
+ @staticmethod
263
+ def extract_code(payload: Mapping[str, Any] | str, fallback_code: str = "") -> str:
264
+ if isinstance(payload, Mapping):
265
+ fallback_code = str(payload.get("yzm") or fallback_code or "")
266
+ sms_text = str(payload.get("sms") or "")
267
+ else:
268
+ sms_text = str(payload or "")
269
+
270
+ digits = CODE_PATTERN.findall(fallback_code)
271
+ if digits:
272
+ return digits[0]
273
+ matches = CODE_PATTERN.findall(sms_text)
274
+ if matches:
275
+ return matches[0]
276
+ return ""
277
+
278
+ def poll_message(
279
+ self,
280
+ phone: str = "",
281
+ sid: str = "",
282
+ timeout: int | None = None,
283
+ interval: int | None = None,
284
+ auto_blacklist: bool = False,
285
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
286
+ ) -> MessageResult:
287
+ sid_value = self._sid(sid)
288
+ phone_value = self._phone(phone)
289
+ wait_timeout = int(timeout or self.wait_timeout)
290
+ poll_interval = int(interval or self.poll_interval)
291
+ deadline = time.monotonic() + wait_timeout
292
+ last_payload: dict[str, Any] | None = None
293
+ attempt = 0
294
+
295
+ while time.monotonic() <= deadline:
296
+ attempt += 1
297
+ last_payload = self.get_message_raw(phone_value, sid=sid_value, allow_wait=True)
298
+ message = MessageResult.from_payload(
299
+ phone=phone_value,
300
+ sid=sid_value,
301
+ payload=last_payload,
302
+ sms_code=self.extract_code(last_payload),
303
+ )
304
+ self.last_message = message
305
+ if on_attempt:
306
+ on_attempt(attempt, message)
307
+ if message.sms_code:
308
+ return message
309
+ sleep_for = min(poll_interval, max(0, deadline - time.monotonic()))
310
+ if sleep_for <= 0:
311
+ break
312
+ time.sleep(sleep_for)
313
+
314
+ if auto_blacklist:
315
+ self.blacklist_phone(phone_value, sid=sid_value)
316
+ raise HaozhuTimeoutError(phone=phone_value, sid=sid_value, last_payload=last_payload)
317
+
318
+ def poll_code(
319
+ self,
320
+ phone: str = "",
321
+ sid: str = "",
322
+ timeout: int | None = None,
323
+ interval: int | None = None,
324
+ auto_blacklist: bool = False,
325
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
326
+ ) -> str:
327
+ return self.poll_message(
328
+ phone=phone,
329
+ sid=sid,
330
+ timeout=timeout,
331
+ interval=interval,
332
+ auto_blacklist=auto_blacklist,
333
+ on_attempt=on_attempt,
334
+ ).sms_code
335
+
336
+ def next_code(
337
+ self,
338
+ sid: str = "",
339
+ timeout: int | None = None,
340
+ interval: int | None = None,
341
+ auto_release: bool = True,
342
+ auto_blacklist: bool = True,
343
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
344
+ **filters: Any,
345
+ ) -> tuple[str, str]:
346
+ phone = self.get_phone(sid=sid, **filters)
347
+ try:
348
+ code = self.poll_code(
349
+ phone=phone,
350
+ sid=sid,
351
+ timeout=timeout,
352
+ interval=interval,
353
+ auto_blacklist=False,
354
+ on_attempt=on_attempt,
355
+ )
356
+ except HaozhuTimeoutError:
357
+ if auto_blacklist:
358
+ self.blacklist(phone=phone, sid=sid)
359
+ raise
360
+ if auto_release:
361
+ self.release(phone=phone, sid=sid)
362
+ return phone, code
363
+
364
+ def release(self, phone: str = "", sid: str = "") -> dict[str, Any]:
365
+ return self.release_phone(phone=phone, sid=sid)
366
+
367
+ def blacklist(self, phone: str = "", sid: str = "") -> dict[str, Any]:
368
+ return self.blacklist_phone(phone=phone, sid=sid)
369
+
370
+ def block(self, phone: str = "", sid: str = "") -> dict[str, Any]:
371
+ return self.blacklist_phone(phone=phone, sid=sid)
372
+
373
+ def close(self) -> None:
374
+ close = getattr(self.session, "close", None)
375
+ if callable(close):
376
+ close()
377
+
378
+ def __enter__(self) -> "HaozhuClient":
379
+ return self
380
+
381
+ def __exit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None:
382
+ self.close()
383
+
384
+
385
+ def login(
386
+ user: str,
387
+ password: str,
388
+ sid: str = "",
389
+ server: str = DEFAULT_SERVER,
390
+ author: str = DEFAULT_AUTHOR,
391
+ timeout: int = 10,
392
+ poll_interval: int = DEFAULT_POLL_INTERVAL,
393
+ wait_timeout: int = DEFAULT_WAIT_TIMEOUT,
394
+ impersonate: str = "chrome120",
395
+ session: Any | None = None,
396
+ use_cache: bool = True,
397
+ token_cache: TokenCache | None = None,
398
+ force: bool = False,
399
+ ) -> HaozhuClient:
400
+ """Create a token-bearing client for batch registration code.
401
+
402
+ By default this reads TokenCache first. If no token exists, it logs in once,
403
+ writes the token cache, and returns a reusable stateful client.
404
+ """
405
+ cache = token_cache or TokenCache()
406
+ if use_cache and not force:
407
+ cached_token = cache.get(server=server, user=user, sid=sid)
408
+ if cached_token:
409
+ return HaozhuClient(
410
+ server=server,
411
+ username=user,
412
+ password=password,
413
+ token=cached_token,
414
+ project_id=sid,
415
+ author=author,
416
+ timeout=timeout,
417
+ poll_interval=poll_interval,
418
+ wait_timeout=wait_timeout,
419
+ impersonate=impersonate,
420
+ session=session,
421
+ )
422
+
423
+ client = HaozhuClient(
424
+ server=server,
425
+ username=user,
426
+ password=password,
427
+ project_id=sid,
428
+ author=author,
429
+ timeout=timeout,
430
+ poll_interval=poll_interval,
431
+ wait_timeout=wait_timeout,
432
+ impersonate=impersonate,
433
+ session=session,
434
+ )
435
+ token = client.login(force=True)
436
+ if use_cache:
437
+ cache.set(server=server, user=user, sid=sid, token=token)
438
+ return client
439
+
440
+
441
+ def from_token(
442
+ token: str,
443
+ sid: str = "",
444
+ server: str = DEFAULT_SERVER,
445
+ author: str = DEFAULT_AUTHOR,
446
+ timeout: int = 10,
447
+ poll_interval: int = DEFAULT_POLL_INTERVAL,
448
+ wait_timeout: int = DEFAULT_WAIT_TIMEOUT,
449
+ impersonate: str = "chrome120",
450
+ session: Any | None = None,
451
+ ) -> HaozhuClient:
452
+ """Create a client from an existing token without calling login."""
453
+ return HaozhuClient(
454
+ server=server,
455
+ token=token,
456
+ project_id=sid,
457
+ author=author,
458
+ timeout=timeout,
459
+ poll_interval=poll_interval,
460
+ wait_timeout=wait_timeout,
461
+ impersonate=impersonate,
462
+ session=session,
463
+ )
haozhuma/exceptions.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+
6
+ class HaozhuError(Exception):
7
+ """Base exception for haozhuma client errors."""
8
+
9
+
10
+ class HaozhuAPIError(HaozhuError):
11
+ """Raised when Haozhuma returns a non-success response."""
12
+
13
+ def __init__(self, api: str, payload: Mapping[str, Any]):
14
+ self.api = api
15
+ self.payload = dict(payload)
16
+ code = self.payload.get("code")
17
+ msg = self.payload.get("msg")
18
+ super().__init__(f"豪猪接口失败: api={api}, code={code}, msg={msg}, payload={self.payload}")
19
+
20
+
21
+ class HaozhuResponseError(HaozhuError):
22
+ """Raised when the HTTP response is not valid JSON or has an invalid shape."""
23
+
24
+
25
+ class HaozhuTimeoutError(HaozhuError, TimeoutError):
26
+ """Raised when polling SMS code times out."""
27
+
28
+ def __init__(self, phone: str, sid: str, last_payload: Mapping[str, Any] | None = None):
29
+ self.phone = str(phone)
30
+ self.sid = str(sid)
31
+ self.last_payload = dict(last_payload or {})
32
+ super().__init__(f"等待验证码超时: phone={self.phone}, sid={self.sid}, last={self.last_payload}")
haozhuma/models.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AccountSummary:
9
+ money: str = ""
10
+ num: int = 0
11
+ raw: dict[str, Any] = field(default_factory=dict)
12
+
13
+ @classmethod
14
+ def from_payload(cls, payload: Mapping[str, Any]) -> "AccountSummary":
15
+ raw = dict(payload)
16
+ try:
17
+ num = int(raw.get("num") or 0)
18
+ except (TypeError, ValueError):
19
+ num = 0
20
+ return cls(
21
+ money=str(raw.get("money") or ""),
22
+ num=num,
23
+ raw=raw,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PhoneResult:
29
+ phone: str
30
+ sid: str = ""
31
+ shop_name: str = ""
32
+ country_name: str = ""
33
+ country_code: str = ""
34
+ country_qu: str = ""
35
+ uid: str = ""
36
+ sp: str = ""
37
+ phone_gsd: str = ""
38
+ raw: dict[str, Any] = field(default_factory=dict)
39
+
40
+ @classmethod
41
+ def from_payload(cls, payload: Mapping[str, Any]) -> "PhoneResult":
42
+ raw = dict(payload)
43
+ return cls(
44
+ phone=str(raw.get("phone") or ""),
45
+ sid=str(raw.get("sid") or ""),
46
+ shop_name=str(raw.get("shop_name") or ""),
47
+ country_name=str(raw.get("country_name") or ""),
48
+ country_code=str(raw.get("country_code") or ""),
49
+ country_qu=str(raw.get("country_qu") or ""),
50
+ uid=str(raw.get("uid") or ""),
51
+ sp=str(raw.get("sp") or ""),
52
+ phone_gsd=str(raw.get("phone_gsd") or ""),
53
+ raw=raw,
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class MessageResult:
59
+ phone: str
60
+ sid: str
61
+ sms: str = ""
62
+ sms_code: str = ""
63
+ api_code: str = ""
64
+ msg: str = ""
65
+ raw: dict[str, Any] = field(default_factory=dict)
66
+
67
+ @property
68
+ def ok(self) -> bool:
69
+ return self.api_code in {"0", "200"} and bool(self.sms_code)
70
+
71
+ @classmethod
72
+ def from_payload(cls, phone: str, sid: str, payload: Mapping[str, Any], sms_code: str = "") -> "MessageResult":
73
+ raw = dict(payload)
74
+ return cls(
75
+ phone=str(phone),
76
+ sid=str(sid),
77
+ sms=str(raw.get("sms") or ""),
78
+ sms_code=str(sms_code or raw.get("yzm") or ""),
79
+ api_code=str(raw.get("code", "")),
80
+ msg=str(raw.get("msg") or ""),
81
+ raw=raw,
82
+ )
haozhuma/provider.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Protocol, runtime_checkable
4
+
5
+ from .models import MessageResult, PhoneResult
6
+
7
+
8
+ @runtime_checkable
9
+ class SMSProvider(Protocol):
10
+ @property
11
+ def token(self) -> str: ...
12
+
13
+ @property
14
+ def phone(self) -> str: ...
15
+
16
+ @property
17
+ def code(self) -> str: ...
18
+
19
+ def get_phone(self, sid: str = "", **filters: Any) -> str: ...
20
+
21
+ def get_phone_info(self, sid: str = "", **filters: Any) -> PhoneResult: ...
22
+
23
+ def poll_code(
24
+ self,
25
+ phone: str = "",
26
+ sid: str = "",
27
+ timeout: int | None = None,
28
+ interval: int | None = None,
29
+ auto_blacklist: bool = False,
30
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
31
+ ) -> str: ...
32
+
33
+ def poll_message(
34
+ self,
35
+ phone: str = "",
36
+ sid: str = "",
37
+ timeout: int | None = None,
38
+ interval: int | None = None,
39
+ auto_blacklist: bool = False,
40
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
41
+ ) -> MessageResult: ...
42
+
43
+ def next_code(
44
+ self,
45
+ sid: str = "",
46
+ timeout: int | None = None,
47
+ interval: int | None = None,
48
+ auto_release: bool = True,
49
+ auto_blacklist: bool = True,
50
+ on_attempt: Callable[[int, MessageResult], None] | None = None,
51
+ **filters: Any,
52
+ ) -> tuple[str, str]: ...
53
+
54
+ def release(self, phone: str = "", sid: str = "") -> dict[str, Any]: ...
55
+
56
+ def blacklist(self, phone: str = "", sid: str = "") -> dict[str, Any]: ...
57
+
58
+ def close(self) -> None: ...
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ TOKEN_CACHE_ENV = "HAOZHUMA_TOKEN_CACHE"
10
+ APP_DIR_NAME = "haozhuma"
11
+ TOKEN_CACHE_FILE_NAME = "token_cache.json"
12
+
13
+
14
+ class TokenCache:
15
+ def __init__(self, path: str | Path | None = None):
16
+ self.path = Path(path).expanduser().resolve() if path else self.default_path()
17
+
18
+ @staticmethod
19
+ def default_path() -> Path:
20
+ override = os.getenv(TOKEN_CACHE_ENV)
21
+ if override:
22
+ return Path(override).expanduser().resolve()
23
+
24
+ appdata = os.getenv("APPDATA")
25
+ if appdata:
26
+ return Path(appdata) / APP_DIR_NAME / TOKEN_CACHE_FILE_NAME
27
+
28
+ return Path.home() / ".cache" / APP_DIR_NAME / TOKEN_CACHE_FILE_NAME
29
+
30
+ @staticmethod
31
+ def make_key(server: str, user: str, sid: str = "") -> str:
32
+ server_value = str(server or "").strip().rstrip("/").lower()
33
+ user_value = str(user or "").strip()
34
+ sid_value = str(sid or "").strip()
35
+ return "|".join([server_value, user_value, sid_value])
36
+
37
+ def load(self) -> dict[str, dict[str, Any]]:
38
+ if not self.path.exists():
39
+ return {}
40
+ try:
41
+ data = json.loads(self.path.read_text(encoding="utf-8"))
42
+ except json.JSONDecodeError:
43
+ return {}
44
+ if not isinstance(data, dict):
45
+ return {}
46
+ return {str(key): dict(value) for key, value in data.items() if isinstance(value, dict)}
47
+
48
+ def save(self, data: dict[str, dict[str, Any]]) -> Path:
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+ self.path.write_text(
51
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n",
52
+ encoding="utf-8",
53
+ )
54
+ return self.path
55
+
56
+ def get(self, server: str, user: str, sid: str = "") -> str:
57
+ key = self.make_key(server, user, sid)
58
+ entry = self.load().get(key) or {}
59
+ return str(entry.get("token") or "").strip()
60
+
61
+ def set(self, server: str, user: str, sid: str, token: str) -> Path:
62
+ key = self.make_key(server, user, sid)
63
+ data = self.load()
64
+ data[key] = {
65
+ "server": str(server or ""),
66
+ "user": str(user or ""),
67
+ "sid": str(sid or ""),
68
+ "token": str(token or ""),
69
+ "updated_at": int(time.time()),
70
+ }
71
+ return self.save(data)
72
+
73
+ def delete(self, server: str, user: str, sid: str = "") -> bool:
74
+ key = self.make_key(server, user, sid)
75
+ data = self.load()
76
+ if key not in data:
77
+ return False
78
+ data.pop(key, None)
79
+ self.save(data)
80
+ return True
81
+
82
+ def clear(self) -> bool:
83
+ if self.path.exists():
84
+ self.path.unlink()
85
+ return True
86
+ return False
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: haozhuma
3
+ Version: 0.1.0
4
+ Summary: Haozhuma SMS receiving platform Python client
5
+ Author: laozig
6
+ License: MIT
7
+ Project-URL: Homepage, https://api.haozhuma.com
8
+ Project-URL: Documentation, https://api.haozhuma.com
9
+ Keywords: haozhuma,sms,otp,verification-code
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: curl_cffi>=0.7.0
22
+
23
+ # haozhuma
24
+
25
+ `haozhuma` 是豪猪接码平台的 **Python 代码 SDK**,面向注册机、批量任务和接码流程封装。
26
+
27
+ 当前版本已经移除了 CLI,只保留代码调用入口。
28
+
29
+ ## 快速启动
30
+
31
+ ```bash
32
+ cd haozhuma
33
+ python -m pip install -e .
34
+ ```
35
+
36
+ ## 环境依赖
37
+
38
+ | 依赖 | 最低版本 | 用途 |
39
+ |---|---:|---|
40
+ | `Python` | `3.9` | 运行 SDK |
41
+ | `curl_cffi` | `0.7.0` | HTTP 客户端 |
42
+
43
+ ## 最短用法
44
+
45
+ ```python
46
+ from haozhuma import login
47
+
48
+ with login("your_user", "your_password", sid="92162") as sms:
49
+ phone, code = sms.next_code()
50
+ print(phone, code)
51
+ ```
52
+
53
+ 这段代码内部完成:
54
+
55
+ - 登录豪猪平台。
56
+ - 复用 `token`。
57
+ - 获取手机号。
58
+ - 轮询验证码。
59
+ - 成功后自动释放号码。
60
+
61
+ ## 状态机用法
62
+
63
+ ```python
64
+ from haozhuma import login
65
+
66
+ sms = login("your_user", "your_password", sid="92162")
67
+
68
+ phone = sms.get_phone()
69
+ code = sms.poll_code()
70
+
71
+ print(phone, code)
72
+ print(sms.phone, sms.code)
73
+
74
+ sms.release()
75
+ ```
76
+
77
+ ## `next_code()` 用法
78
+
79
+ `next_code()` 适合注册机主循环。
80
+
81
+ ```python
82
+ from haozhuma import login
83
+
84
+ sms = login("your_user", "your_password", sid="92162")
85
+
86
+ for index in range(100):
87
+ try:
88
+ phone, code = sms.next_code()
89
+ print(index, phone, code)
90
+ except TimeoutError:
91
+ print(index, "timeout")
92
+ ```
93
+
94
+ 默认行为:
95
+
96
+ | 参数 | 默认值 | 作用 |
97
+ |---|---|---|
98
+ | `auto_release` | `True` | 取码成功后自动释放号码 |
99
+ | `auto_blacklist` | `True` | 超时后自动拉黑号码 |
100
+
101
+ ## `TokenCache` 用法
102
+
103
+ `login()` 默认自动读写 [`TokenCache`](src/haozhuma/token_cache.py:14)。
104
+
105
+ - 命中缓存:直接复用 `token`
106
+ - 未命中缓存:重新登录并写回缓存
107
+
108
+ ```python
109
+ from haozhuma import TokenCache, login
110
+
111
+ cache = TokenCache("./token_cache.json")
112
+ sms = login("your_user", "your_password", sid="92162", token_cache=cache)
113
+ ```
114
+
115
+ 如果你不想用缓存:
116
+
117
+ ```python
118
+ from haozhuma import login
119
+
120
+ sms = login("your_user", "your_password", sid="92162", use_cache=False)
121
+ ```
122
+
123
+ ## 已有 `token` 的用法
124
+
125
+ ```python
126
+ from haozhuma import from_token
127
+
128
+ sms = from_token("your_token", sid="92162")
129
+ phone = sms.get_phone()
130
+ code = sms.poll_code()
131
+ ```
132
+
133
+ ## 保留完整响应对象
134
+
135
+ 如果你不只要字符串,也可以拿完整结构。
136
+
137
+ ```python
138
+ from haozhuma import login
139
+
140
+ sms = login("your_user", "your_password", sid="92162")
141
+
142
+ phone_info = sms.get_phone_info()
143
+ message = sms.poll_message()
144
+
145
+ print(phone_info.raw)
146
+ print(message.raw)
147
+ ```
148
+
149
+ ## `SMSProvider` 抽象
150
+
151
+ [`SMSProvider`](src/haozhuma/provider.py:9) 是统一协议,方便以后接别的接码平台。
152
+
153
+ ```python
154
+ from haozhuma import SMSProvider, login
155
+
156
+ sms: SMSProvider = login("your_user", "your_password", sid="92162")
157
+ phone, code = sms.next_code()
158
+ ```
159
+
160
+ ## API 对照
161
+
162
+ | SDK 方法 | 返回值 | 说明 |
163
+ |---|---|---|
164
+ | `login()` | `HaozhuClient` | 登录并返回可复用对象,默认自动复用 `TokenCache` |
165
+ | `from_token()` | `HaozhuClient` | 用已有 `token` 恢复对象 |
166
+ | `get_phone()` | `str` | 获取号码并保存状态 |
167
+ | `get_phone_info()` | `PhoneResult` | 获取号码完整响应 |
168
+ | `poll_code()` | `str` | 轮询验证码并保存状态 |
169
+ | `poll_message()` | `MessageResult` | 轮询短信完整响应 |
170
+ | `next_code()` | `(phone, code)` | 一行完成取号 + 取码 |
171
+ | `release()` | `dict` | 释放当前号码 |
172
+ | `blacklist()` | `dict` | 拉黑当前号码 |
173
+ | `TokenCache` | `class` | 管理登录态缓存 |
174
+ | `SMSProvider` | `Protocol` | 接码平台统一抽象 |
175
+
176
+ ## 当前目录说明
177
+
178
+ | 文件 | 是否保留 | 说明 |
179
+ |---|---|---|
180
+ | `src/haozhuma/client.py` | 保留 | 核心客户端 |
181
+ | `src/haozhuma/token_cache.py` | 保留 | token 缓存 |
182
+ | `src/haozhuma/provider.py` | 保留 | 平台抽象协议 |
183
+ | `src/haozhuma/models.py` | 保留 | 响应模型 |
184
+ | `src/haozhuma/exceptions.py` | 保留 | 异常定义 |
185
+ | `src/haozhuma/__init__.py` | 保留 | 对外导出 |
186
+ | `pyproject.toml` | 保留 | `pip install` 打包入口 |
187
+ | `README.md` | 保留 | 用法文档 |
188
+
189
+ ## 免责声明
190
+
191
+ 本模块仅用于已授权项目的验证码接收、测试环境验证和自动化流程开发。使用者需自行确认账号、项目和接口调用均符合平台规则与当地法律。
@@ -0,0 +1,10 @@
1
+ haozhuma/__init__.py,sha256=gx4Ui8CNa9C_NmCz7hL_X2yOmjioj79vJfCxstWpv7g,582
2
+ haozhuma/client.py,sha256=7ug1WMjgsgSpsjNU0icHQUo4c9hyhPf4mkrEHIwTaiM,16020
3
+ haozhuma/exceptions.py,sha256=XkeX5dN-5DcvKuh3nHijIBqXucSpIkGn_DRW9RK_WvI,1143
4
+ haozhuma/models.py,sha256=4tlL3LKIg09dz1tbItJtiUG0XL4TLSh_XQJNsyBUQDA,2395
5
+ haozhuma/provider.py,sha256=KLn8AXG8dgtvtISIvLtOAf4M_4-km-rSxyv0Tvi54Es,1641
6
+ haozhuma/token_cache.py,sha256=m5Gb-vp9axlopeCbxeUnEq1PpI4llAgPFr8Fde1rZJY,2878
7
+ haozhuma-0.1.0.dist-info/METADATA,sha256=idTCLF7uVksnUgMGmSqIT4LD03eJz-dJpMGgIX4fQ5o,5228
8
+ haozhuma-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ haozhuma-0.1.0.dist-info/top_level.txt,sha256=ls6AnC1j0U2-OVa60J0BdgQcZLEdKxRpRjZANXOtw6U,9
10
+ haozhuma-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ haozhuma