shindan-cli 2.2.1__tar.gz → 2.2.2__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: shindan-cli
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: ShindanMaker (https://shindanmaker.com) CLI
5
5
  Project-URL: Repository, https://github.com/eggplants/shindan-cli
6
6
  Author-email: eggplants <w10776e8w@yahoo.co.jp>
@@ -57,7 +57,7 @@ Supported types of diagnosis:
57
57
 
58
58
  - [Name-based diagnosis (名前診断)](https://shindanmaker.com/list/name)
59
59
  - [Branching diagnosis (分岐診断)](https://shindanmaker.com/list/branch)
60
- - [AI diagnosis (AI診断)](https://shindanmaker.com/list/ai)
60
+ - [AI diagnosis (AI診断)](https://shindanmaker.com/list/ai) (currently blocked by a Cloudflare Turnstile challenge)
61
61
  - [Check Diagnosis (チェック診断)](https://shindanmaker.com/list/check)
62
62
 
63
63
  ### CLI
@@ -32,7 +32,7 @@ Supported types of diagnosis:
32
32
 
33
33
  - [Name-based diagnosis (名前診断)](https://shindanmaker.com/list/name)
34
34
  - [Branching diagnosis (分岐診断)](https://shindanmaker.com/list/branch)
35
- - [AI diagnosis (AI診断)](https://shindanmaker.com/list/ai)
35
+ - [AI diagnosis (AI診断)](https://shindanmaker.com/list/ai) (currently blocked by a Cloudflare Turnstile challenge)
36
36
  - [Check Diagnosis (チェック診断)](https://shindanmaker.com/list/check)
37
37
 
38
38
  ### CLI
@@ -0,0 +1,79 @@
1
+ """HTTP helpers to cope with rate limiting on <https://shindanmaker.com>."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ if TYPE_CHECKING:
10
+ from requests import Response, Session
11
+
12
+ TOO_MANY_REQUESTS = 429
13
+
14
+ MAX_RETRIES = 5
15
+ INITIAL_WAIT = 2.0
16
+ BACKOFF_FACTOR = 2.0
17
+
18
+ WAIT_RANGE = (2.0, 5.0)
19
+
20
+
21
+ def random_wait() -> None:
22
+ """Sleep for a random while, to fetch at a less machine-like pace."""
23
+ time.sleep(random.uniform(*WAIT_RANGE)) # noqa: S311
24
+
25
+
26
+ def backoff_wait(attempt: int) -> float:
27
+ """Get how long to wait before the given retry attempt.
28
+
29
+ Args:
30
+ attempt (int): 0-based index of the retry about to be made
31
+
32
+ Returns:
33
+ float: seconds to sleep
34
+
35
+ """
36
+ return INITIAL_WAIT * BACKOFF_FACTOR**attempt
37
+
38
+
39
+ def request_with_retry(
40
+ session: Session,
41
+ method: str,
42
+ url: str,
43
+ **kwargs: Any, # noqa: ANN401
44
+ ) -> Response:
45
+ """Send a request, retrying with a backoff while the site rate limits us.
46
+
47
+ The site is fronted by Cloudflare, which answers bursts of requests with a
48
+ `429` challenge page instead of the result. Those responses are transient,
49
+ so they are retried rather than reported to the caller.
50
+
51
+ Args:
52
+ session (Session): session object
53
+ method (str): HTTP method
54
+ url (str): url to request
55
+ **kwargs (Any): extra arguments passed to `Session.request`
56
+
57
+ Returns:
58
+ Response: the last response received
59
+
60
+ """
61
+ response = session.request(method, url, **kwargs)
62
+ for attempt in range(MAX_RETRIES - 1):
63
+ if response.status_code != TOO_MANY_REQUESTS:
64
+ break
65
+ time.sleep(backoff_wait(attempt))
66
+ response = session.request(method, url, **kwargs)
67
+ return response
68
+
69
+
70
+ __all__ = (
71
+ "BACKOFF_FACTOR",
72
+ "INITIAL_WAIT",
73
+ "MAX_RETRIES",
74
+ "TOO_MANY_REQUESTS",
75
+ "WAIT_RANGE",
76
+ "backoff_wait",
77
+ "random_wait",
78
+ "request_with_retry",
79
+ )
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast
8
8
 
9
9
  from bs4 import BeautifulSoup, Tag
10
10
 
11
+ from ._http import MAX_RETRIES, backoff_wait, request_with_retry
11
12
  from .constants import HEADERS, AIParams, BranchParams, CheckParams, NameParams
12
13
 
13
14
  if TYPE_CHECKING:
@@ -17,9 +18,6 @@ if TYPE_CHECKING:
17
18
 
18
19
  Params = AIParams | BranchParams | CheckParams | NameParams
19
20
 
20
- _MAX_RETRIES = 3
21
- _RETRY_WAIT = 4.0
22
-
23
21
 
24
22
  def __get_result(
25
23
  session: Session,
@@ -29,15 +27,19 @@ def __get_result(
29
27
  shindan_url: str,
30
28
  ) -> ShindanResult:
31
29
  result_tag: Tag | None = None
30
+ status_code = None
32
31
 
33
- for attempt in range(_MAX_RETRIES):
32
+ for attempt in range(MAX_RETRIES):
34
33
  if attempt > 0:
35
- time.sleep(_RETRY_WAIT)
36
- result_page = session.post(
34
+ time.sleep(backoff_wait(attempt - 1))
35
+ result_page = request_with_retry(
36
+ session,
37
+ "POST",
37
38
  shindan_url + ("/r" if is_renewal else ""),
38
39
  data=params,
39
40
  headers=HEADERS,
40
41
  )
42
+ status_code = result_page.status_code
41
43
  soup = BeautifulSoup(result_page.text, features="lxml")
42
44
  found = soup.find(id="share-copytext-shindanresult-textarea")
43
45
  if isinstance(found, Tag) and found.text:
@@ -45,7 +47,7 @@ def __get_result(
45
47
  break
46
48
 
47
49
  if result_tag is None:
48
- msg = f"Could not find a tag contains the result, returns: {result_tag}"
50
+ msg = f"Could not find a tag contains the result, last status code: {status_code}"
49
51
  raise TypeError(msg)
50
52
 
51
53
  *results, hashtag, shindan_url, _ = result_tag.text.split("\n")
@@ -85,7 +87,9 @@ def get_result_by_ai(
85
87
  """
86
88
  ai_headers = {**HEADERS, "x-csrf-token": csrf_token}
87
89
 
88
- result_sse = session.post(
90
+ result_sse = request_with_retry(
91
+ session,
92
+ "POST",
89
93
  f"{shindan_url}/ai_result",
90
94
  json={
91
95
  "form_values": user_inputs,
@@ -2,14 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import random
6
- import time
7
5
  from typing import cast
8
6
 
9
7
  import cloudscraper # type: ignore[unused-ignore,import-not-found,import-untyped]
10
8
  from bs4 import BeautifulSoup
11
9
  from requests.sessions import Session
12
10
 
11
+ from . import _http
12
+ from ._http import request_with_retry
13
13
  from .constants import BASE_URL, HEADERS, TARGET_KEYS_BY_TYPE
14
14
  from .get_results import (
15
15
  Params,
@@ -26,6 +26,42 @@ class ShindanError(Exception):
26
26
  """Error class for shindan-cli."""
27
27
 
28
28
 
29
+ def __get_csrf_token(session: Session, source: BeautifulSoup) -> str:
30
+ """Get a CSRF token to submit the shindan form with.
31
+
32
+ The shindan pages are served from a cache with their token fields left
33
+ blank, and the browser fills them in from `/csrf-token` just before
34
+ submitting the form.
35
+
36
+ Args:
37
+ session (Session): session object
38
+ source (BeautifulSoup): parsed shindan page
39
+
40
+ Returns:
41
+ str: CSRF token
42
+
43
+ Raises:
44
+ ShindanError
45
+
46
+ """
47
+ csrf_meta = source.select_one('meta[name="csrf-token"]')
48
+ if csrf_meta and isinstance(token := csrf_meta.get("content"), str) and token:
49
+ return token
50
+
51
+ response = request_with_retry(
52
+ session,
53
+ "GET",
54
+ f"{BASE_URL}/csrf-token",
55
+ headers={**HEADERS, "X-Requested-With": "XMLHttpRequest"},
56
+ )
57
+ if response.status_code != 200: # noqa: PLR2004
58
+ raise ShindanError(response.status_code)
59
+ if not isinstance(token := response.json().get("token"), str) or not token:
60
+ msg = "Could not find CSRF token on the shindan page."
61
+ raise ShindanError(msg)
62
+ return token
63
+
64
+
29
65
  def shindan(
30
66
  page_id: int,
31
67
  shindan_name: str,
@@ -57,7 +93,7 @@ def shindan(
57
93
  session = cloudscraper.create_scraper()
58
94
  assert isinstance(session, Session) # noqa: S101
59
95
 
60
- shindan_page = session.get(shindan_url, headers=HEADERS)
96
+ shindan_page = request_with_retry(session, "GET", shindan_url, headers=HEADERS)
61
97
  if shindan_page.status_code != 200: # noqa: PLR2004
62
98
  raise ShindanError(shindan_page.status_code)
63
99
 
@@ -78,9 +114,10 @@ def shindan(
78
114
  )
79
115
  # overwrite randname (old: shindanName)
80
116
  params["randname"] = shindan_name
117
+ params["_token"] = params["_token"] or __get_csrf_token(session, source)
81
118
 
82
119
  if wait:
83
- time.sleep(random.uniform(2, 5)) # noqa: S311
120
+ _http.random_wait()
84
121
 
85
122
  if params["type"] == "ai":
86
123
  hashtag_title = source.select_one("h1#shindanTitle")
@@ -89,16 +126,12 @@ def shindan(
89
126
  str,
90
127
  ):
91
128
  hashtag = None
92
- csrf_meta = source.select_one('meta[name="csrf-token"]')
93
- if not csrf_meta or not isinstance(csrf_token := csrf_meta.get("content"), str):
94
- msg = "Could not find CSRF token on the shindan page."
95
- raise ShindanError(msg)
96
129
  return get_result_by_ai(
97
130
  session,
98
131
  params,
99
132
  user_inputs=get_user_inputs(source, shindan_name),
100
133
  hashtag=hashtag,
101
- csrf_token=csrf_token,
134
+ csrf_token=params["_token"],
102
135
  shindan_url=shindan_url,
103
136
  )
104
137
  if params["type"] == "branch":
File without changes
File without changes
@@ -47,9 +47,9 @@ docs = [
47
47
  ]
48
48
 
49
49
  [tool.hatch]
50
- build.targets.sdist.include = [ "shindan_cli" ]
51
- build.targets.wheel.include = [ "shindan_cli" ]
52
50
  version.source = "uv-dynamic-versioning"
51
+ build.targets.wheel.include = [ "shindan_cli" ]
52
+ build.targets.sdist.include = [ "shindan_cli" ]
53
53
 
54
54
  [tool.uv]
55
55
  python-downloads = "never"