shindan-cli 2.2.0__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,10 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: shindan-cli
3
- Version: 2.2.0
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>
7
- License: MIT
7
+ License-Expression: MIT
8
8
  License-File: LICENSE
9
9
  Keywords: cli,shindanmaker
10
10
  Classifier: Development Status :: 3 - Alpha
@@ -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
@@ -1,6 +1,5 @@
1
1
  [build-system]
2
2
  build-backend = "hatchling.build"
3
-
4
3
  requires = [ "hatchling", "uv-dynamic-versioning" ]
5
4
 
6
5
  [project]
@@ -11,7 +10,7 @@ keywords = [
11
10
  "cli",
12
11
  "shindanmaker",
13
12
  ]
14
- license = { text = "MIT" }
13
+ license = "MIT"
15
14
  authors = [ { name = "eggplants", email = "w10776e8w@yahoo.co.jp" } ]
16
15
  requires-python = ">=3.10,<4"
17
16
  classifiers = [
@@ -32,7 +31,6 @@ dependencies = [
32
31
  "lxml>=5.3,<7",
33
32
  "requests>=2.32.3,<3",
34
33
  ]
35
-
36
34
  urls.Repository = "https://github.com/eggplants/shindan-cli"
37
35
  scripts.shindan = "shindan_cli.main:main"
38
36
 
@@ -48,22 +46,19 @@ docs = [
48
46
  "pdoc>=16",
49
47
  ]
50
48
 
51
- [tool.hatch.version]
52
- source = "uv-dynamic-versioning"
53
-
54
- [tool.hatch.build.targets.sdist]
55
- include = [ "shindan_cli" ]
56
-
57
- [tool.hatch.build.targets.wheel]
58
- include = [ "shindan_cli" ]
49
+ [tool.hatch]
50
+ version.source = "uv-dynamic-versioning"
51
+ build.targets.wheel.include = [ "shindan_cli" ]
52
+ build.targets.sdist.include = [ "shindan_cli" ]
59
53
 
60
54
  [tool.uv]
61
- preview = true
55
+ python-downloads = "never"
62
56
  default-groups = [
63
57
  "dev",
64
58
  "docs",
65
59
  ]
66
60
  exclude-newer = "P7D"
61
+ preview = true
67
62
 
68
63
  [tool.ruff]
69
64
  line-length = 120
@@ -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
+ )
@@ -7,34 +7,26 @@ class AIParams(TypedDict):
7
7
  _token: str
8
8
  randname: str
9
9
  type: Literal["ai"]
10
- shindan_token: str
11
10
  encrypted_exec_key: str
12
11
 
13
12
 
14
13
  class BranchParams(TypedDict):
15
14
  _token: str
16
15
  randname: str
17
- hiddenName: str
18
16
  type: Literal["branch"]
19
- shindan_token: str
20
17
  rbr: str
21
18
 
22
19
 
23
20
  class CheckParams(TypedDict):
24
21
  _token: str
25
22
  randname: str
26
- hiddenName: str
27
23
  type: Literal["check"]
28
- shindan_token: str
29
- # input-check-choice[choice_id]: str
30
24
 
31
25
 
32
26
  class NameParams(TypedDict):
33
27
  _token: str
34
28
  randname: str
35
- hiddenName: str
36
29
  type: Literal["name"]
37
- shindan_token: str
38
30
 
39
31
 
40
32
  class TargetKeysByType(TypedDict):
@@ -3,18 +3,20 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from typing import TYPE_CHECKING, Union
6
+ import time
7
+ from typing import TYPE_CHECKING, cast
7
8
 
8
9
  from bs4 import BeautifulSoup, Tag
9
10
 
10
- from .constants import BASE_URL, HEADERS, AIParams, BranchParams, CheckParams, NameParams
11
+ from ._http import MAX_RETRIES, backoff_wait, request_with_retry
12
+ from .constants import HEADERS, AIParams, BranchParams, CheckParams, NameParams
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from requests import Session
14
16
 
15
17
  from .models import ShindanResult, UserInputs
16
18
 
17
- Params = Union[AIParams, BranchParams, CheckParams, NameParams]
19
+ Params = AIParams | BranchParams | CheckParams | NameParams
18
20
 
19
21
 
20
22
  def __get_result(
@@ -24,16 +26,28 @@ def __get_result(
24
26
  is_renewal: bool = False,
25
27
  shindan_url: str,
26
28
  ) -> ShindanResult:
27
- result_page = session.post(
28
- shindan_url + ("/r" if is_renewal else ""),
29
- data=params,
30
- headers=HEADERS,
31
- )
32
- soup = BeautifulSoup(result_page.text, features="lxml")
33
- result_tag = soup.find(id="share-copytext-shindanresult-textarea")
34
-
35
- if not isinstance(result_tag, Tag) or not result_tag.text:
36
- msg = f"Could not find a tag contains the result, returns: {result_tag}"
29
+ result_tag: Tag | None = None
30
+ status_code = None
31
+
32
+ for attempt in range(MAX_RETRIES):
33
+ if attempt > 0:
34
+ time.sleep(backoff_wait(attempt - 1))
35
+ result_page = request_with_retry(
36
+ session,
37
+ "POST",
38
+ shindan_url + ("/r" if is_renewal else ""),
39
+ data=params,
40
+ headers=HEADERS,
41
+ )
42
+ status_code = result_page.status_code
43
+ soup = BeautifulSoup(result_page.text, features="lxml")
44
+ found = soup.find(id="share-copytext-shindanresult-textarea")
45
+ if isinstance(found, Tag) and found.text:
46
+ result_tag = found
47
+ break
48
+
49
+ if result_tag is None:
50
+ msg = f"Could not find a tag contains the result, last status code: {status_code}"
37
51
  raise TypeError(msg)
38
52
 
39
53
  *results, hashtag, shindan_url, _ = result_tag.text.split("\n")
@@ -54,6 +68,7 @@ def get_result_by_ai(
54
68
  *,
55
69
  user_inputs: UserInputs,
56
70
  hashtag: str | None,
71
+ csrf_token: str,
57
72
  shindan_url: str,
58
73
  ) -> ShindanResult:
59
74
  """Get result by AI type shindan.
@@ -63,36 +78,18 @@ def get_result_by_ai(
63
78
  params (Params): input parameters fetched from shindan page
64
79
  user_inputs (UserInputs): user inputs
65
80
  hashtag (str | None): hashtag
81
+ csrf_token (str): CSRF token extracted from the shindan page
66
82
  shindan_url (str): shindan url
67
83
 
68
84
  Returns:
69
85
  ShindanResult: the returned result from <https://shindanmaker.com>
70
86
 
71
87
  """
72
- res = session.post(
73
- shindan_url,
74
- data=params,
75
- headers=HEADERS,
76
- )
77
- meta = BeautifulSoup(
78
- res.text,
79
- features="lxml",
80
- ).select_one('meta[name="csrf-token"]')
81
- if not res.ok or not meta or not isinstance(csrf_token := meta.get("content"), str):
82
- msg = f"Failed to get the csrf token. ({res.status_code})"
83
- raise ValueError(msg)
84
- HEADERS.update({"x-csrf-token": csrf_token})
85
- if not res.ok:
86
- res = session.post(
87
- f"{BASE_URL}/ai_life_update",
88
- data={"ai_life": 3},
89
- headers=HEADERS,
90
- )
91
- if not res.ok:
92
- msg = f"Failed to update AI life. ({res.status_code})"
93
- raise ValueError(msg)
88
+ ai_headers = {**HEADERS, "x-csrf-token": csrf_token}
94
89
 
95
- result_sse = session.post(
90
+ result_sse = request_with_retry(
91
+ session,
92
+ "POST",
96
93
  f"{shindan_url}/ai_result",
97
94
  json={
98
95
  "form_values": user_inputs,
@@ -100,7 +97,7 @@ def get_result_by_ai(
100
97
  "ai_result_request_times": 0,
101
98
  "encrypted_exec_key": params["encrypted_exec_key"],
102
99
  },
103
- headers=HEADERS,
100
+ headers=ai_headers,
104
101
  )
105
102
  gpt_results = "".join(
106
103
  re.findall(r'"content":"([^"]+)', result_sse.text),
@@ -151,8 +148,9 @@ def get_result_by_check(
151
148
  ShindanResult: the returned result from <https://shindanmaker.com>
152
149
 
153
150
  """
151
+ check_params = cast("dict[str, str]", params)
154
152
  for choice_id, answer_id in user_choices.items():
155
- params[f"input-check-choice[{choice_id}]"] = answer_id # type: ignore[literal-required]
153
+ check_params[f"input-check-choice[{choice_id}]"] = answer_id
156
154
 
157
155
  return __get_result(session, params, is_renewal=True, shindan_url=shindan_url)
158
156
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from shindan_cli.models import QuestionBranchChoice, QuestionChoiceChoice, UserInput, QuestionBranch, QuestionChoice
7
+ from shindan_cli.models import QuestionBranch, QuestionBranchChoice, QuestionChoice, QuestionChoiceChoice, UserInput
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from bs4 import BeautifulSoup
@@ -27,7 +27,7 @@ def get_user_inputs(
27
27
 
28
28
  """
29
29
  user_inputs: dict[str, UserInput] = {}
30
- form_labels = source.select("form#shindanForm > div.px-3 > div > span")
30
+ form_labels = source.select("form#shindanForm label")
31
31
  for idx, question in enumerate([*form_labels, *range(10 - len(form_labels))]):
32
32
  if isinstance(question, int):
33
33
  user_inputs[f"user_input_{idx + 1}"] = UserInput({"q": "", "a": None})
@@ -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,
@@ -55,10 +91,10 @@ def shindan(
55
91
  shindan_url = f"{BASE_URL}/{page_id}"
56
92
 
57
93
  session = cloudscraper.create_scraper()
58
- assert isinstance(session, Session)
94
+ assert isinstance(session, Session) # noqa: S101
59
95
 
60
- shindan_page = session.get(shindan_url, headers=HEADERS)
61
- if shindan_page.status_code != 200:
96
+ shindan_page = request_with_retry(session, "GET", shindan_url, headers=HEADERS)
97
+ if shindan_page.status_code != 200: # noqa: PLR2004
62
98
  raise ShindanError(shindan_page.status_code)
63
99
 
64
100
  source = BeautifulSoup(shindan_page.text, features="lxml")
@@ -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")
@@ -94,6 +131,7 @@ def shindan(
94
131
  params,
95
132
  user_inputs=get_user_inputs(source, shindan_name),
96
133
  hashtag=hashtag,
134
+ csrf_token=params["_token"],
97
135
  shindan_url=shindan_url,
98
136
  )
99
137
  if params["type"] == "branch":
File without changes
File without changes