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.
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/PKG-INFO +3 -3
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/README.md +1 -1
- shindan_cli-2.2.2/shindan_cli/_http.py +79 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/get_results.py +12 -8
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/shindan.py +42 -9
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/.gitignore +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/LICENSE +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/pyproject.toml +2 -2
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/__init__.py +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/constants.py +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/interactive.py +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/main.py +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/models.py +0 -0
- {shindan_cli-2.2.1 → shindan_cli-2.2.2}/shindan_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
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>
|
|
@@ -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(
|
|
32
|
+
for attempt in range(MAX_RETRIES):
|
|
34
33
|
if attempt > 0:
|
|
35
|
-
time.sleep(
|
|
36
|
-
result_page =
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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=
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|