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.
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/PKG-INFO +4 -4
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/README.md +1 -1
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/pyproject.toml +7 -12
- shindan_cli-2.2.2/shindan_cli/_http.py +79 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/constants.py +0 -8
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/get_results.py +36 -38
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/interactive.py +2 -2
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/shindan.py +44 -6
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/.gitignore +0 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/LICENSE +0 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/__init__.py +0 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/main.py +0 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/models.py +0 -0
- {shindan_cli-2.2.0 → shindan_cli-2.2.2}/shindan_cli/py.typed +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: shindan-cli
|
|
3
|
-
Version: 2.2.
|
|
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 =
|
|
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
|
|
52
|
-
source = "uv-dynamic-versioning"
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
7
8
|
|
|
8
9
|
from bs4 import BeautifulSoup, Tag
|
|
9
10
|
|
|
10
|
-
from .
|
|
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 =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|