capskip 1.0.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.
- capskip/__init__.py +29 -0
- capskip/_api_params.py +118 -0
- capskip/api.py +56 -0
- capskip/async_api.py +63 -0
- capskip/async_solver.py +123 -0
- capskip/exceptions.py +21 -0
- capskip/solver.py +163 -0
- capskip-1.0.0.dist-info/METADATA +301 -0
- capskip-1.0.0.dist-info/RECORD +12 -0
- capskip-1.0.0.dist-info/WHEEL +5 -0
- capskip-1.0.0.dist-info/licenses/LICENSE +21 -0
- capskip-1.0.0.dist-info/top_level.txt +1 -0
capskip/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CapSkip Python SDK — local captcha solver client."""
|
|
2
|
+
|
|
3
|
+
from .api import ApiClient
|
|
4
|
+
from .async_api import AsyncApiClient
|
|
5
|
+
from .async_solver import AsyncCapSkip
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
ApiException,
|
|
8
|
+
CapSkipError,
|
|
9
|
+
NetworkException,
|
|
10
|
+
SolverExceptions,
|
|
11
|
+
TimeoutException,
|
|
12
|
+
ValidationException,
|
|
13
|
+
)
|
|
14
|
+
from .solver import CapSkip
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'CapSkip',
|
|
18
|
+
'AsyncCapSkip',
|
|
19
|
+
'ApiClient',
|
|
20
|
+
'AsyncApiClient',
|
|
21
|
+
'CapSkipError',
|
|
22
|
+
'SolverExceptions',
|
|
23
|
+
'ValidationException',
|
|
24
|
+
'NetworkException',
|
|
25
|
+
'ApiException',
|
|
26
|
+
'TimeoutException',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = '1.0.0'
|
capskip/_api_params.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""CapSkip API parameter validation (https://capskip.com/api-docs/)."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import ValidationException
|
|
4
|
+
|
|
5
|
+
NORMAL_SUBMIT = frozenset({'method', 'body', 'json', 'file'})
|
|
6
|
+
|
|
7
|
+
RECAPTCHA_V2_SUBMIT = frozenset({
|
|
8
|
+
'method', 'googlekey', 'pageurl', 'enterprise', 'invisible', 'data-s', 'json',
|
|
9
|
+
'proxy', 'proxytype',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
RECAPTCHA_V3_SUBMIT = frozenset({
|
|
13
|
+
'method', 'version', 'googlekey', 'pageurl', 'enterprise', 'action', 'min_score',
|
|
14
|
+
'json', 'proxy', 'proxytype',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
TURNSTILE_SUBMIT = frozenset({
|
|
18
|
+
'method', 'sitekey', 'pageurl', 'action', 'data', 'pagedata', 'json',
|
|
19
|
+
'proxy', 'proxytype',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
_PARAM_ALIASES = {
|
|
23
|
+
'url': 'pageurl',
|
|
24
|
+
'score': 'min_score',
|
|
25
|
+
'minScore': 'min_score',
|
|
26
|
+
'datas': 'data-s',
|
|
27
|
+
'data_s': 'data-s',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_param_aliases(params: dict) -> dict:
|
|
32
|
+
out = dict(params)
|
|
33
|
+
for alias, api_name in _PARAM_ALIASES.items():
|
|
34
|
+
if alias in out:
|
|
35
|
+
if api_name in out and out[alias] != out[api_name]:
|
|
36
|
+
raise ValidationException(
|
|
37
|
+
f"Conflicting parameters: {alias!r} and {api_name!r}"
|
|
38
|
+
)
|
|
39
|
+
out[api_name] = out.pop(alias)
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def apply_proxy(params: dict) -> dict:
|
|
44
|
+
out = dict(params)
|
|
45
|
+
proxy = out.pop('proxy', None)
|
|
46
|
+
if not proxy:
|
|
47
|
+
return out
|
|
48
|
+
if isinstance(proxy, dict):
|
|
49
|
+
if 'uri' not in proxy or 'type' not in proxy:
|
|
50
|
+
raise ValidationException("proxy dict must contain 'type' and 'uri' keys")
|
|
51
|
+
out['proxy'] = proxy['uri']
|
|
52
|
+
out['proxytype'] = proxy['type']
|
|
53
|
+
else:
|
|
54
|
+
out['proxy'] = proxy
|
|
55
|
+
if 'proxytype' not in out:
|
|
56
|
+
out['proxytype'] = 'HTTP'
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unknown_keys(params: dict, allowed: frozenset) -> set:
|
|
61
|
+
return set(params.keys()) - allowed - {'key', 'file', 'files'}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_normal_submit(params: dict) -> None:
|
|
65
|
+
unknown = _unknown_keys(params, NORMAL_SUBMIT)
|
|
66
|
+
if unknown:
|
|
67
|
+
raise ValidationException(
|
|
68
|
+
f"Unsupported parameters for image captcha: {sorted(unknown)}. "
|
|
69
|
+
f"CapSkip only supports: method, file/body, json."
|
|
70
|
+
)
|
|
71
|
+
if 'proxy' in params or 'proxytype' in params:
|
|
72
|
+
raise ValidationException(
|
|
73
|
+
"Proxy is not supported for image captcha. "
|
|
74
|
+
"Use proxy only with reCAPTCHA or Turnstile."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_recaptcha_submit(params: dict, version: str) -> None:
|
|
79
|
+
version = (version or 'v2').lower()
|
|
80
|
+
if version == 'v3':
|
|
81
|
+
allowed = RECAPTCHA_V3_SUBMIT
|
|
82
|
+
if params.get('invisible'):
|
|
83
|
+
raise ValidationException("invisible is only supported for reCAPTCHA v2.")
|
|
84
|
+
else:
|
|
85
|
+
allowed = RECAPTCHA_V2_SUBMIT
|
|
86
|
+
if params.get('version') == 'v3':
|
|
87
|
+
raise ValidationException("Use version='v3' for reCAPTCHA v3.")
|
|
88
|
+
for key in ('action', 'min_score'):
|
|
89
|
+
if key in params:
|
|
90
|
+
raise ValidationException(f"{key!r} is only supported for reCAPTCHA v3.")
|
|
91
|
+
|
|
92
|
+
unknown = _unknown_keys(params, allowed)
|
|
93
|
+
if unknown:
|
|
94
|
+
raise ValidationException(
|
|
95
|
+
f"Unsupported parameters for reCAPTCHA {version}: {sorted(unknown)}."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_turnstile_submit(params: dict) -> None:
|
|
100
|
+
unknown = _unknown_keys(params, TURNSTILE_SUBMIT)
|
|
101
|
+
if unknown:
|
|
102
|
+
raise ValidationException(
|
|
103
|
+
f"Unsupported parameters for Turnstile: {sorted(unknown)}."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prepare_submit_params(params: dict, captcha_type: str, version: str = 'v2') -> dict:
|
|
108
|
+
params = apply_param_aliases(params)
|
|
109
|
+
params = apply_proxy(params)
|
|
110
|
+
|
|
111
|
+
if captcha_type == 'normal':
|
|
112
|
+
validate_normal_submit(params)
|
|
113
|
+
elif captcha_type == 'recaptcha':
|
|
114
|
+
validate_recaptcha_submit(params, version)
|
|
115
|
+
elif captcha_type == 'turnstile':
|
|
116
|
+
validate_turnstile_submit(params)
|
|
117
|
+
|
|
118
|
+
return params
|
capskip/api.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from .exceptions import ApiException, NetworkException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiClient:
|
|
7
|
+
"""Low-level HTTP client for CapSkip in.php / res.php endpoints."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, host='127.0.0.1', port=8080):
|
|
10
|
+
self.host = host
|
|
11
|
+
self.port = port
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def base_url(self):
|
|
15
|
+
return f'http://{self.host}:{self.port}'
|
|
16
|
+
|
|
17
|
+
def in_(self, files=None, **kwargs):
|
|
18
|
+
files = files or {}
|
|
19
|
+
try:
|
|
20
|
+
url = f'{self.base_url}/in.php'
|
|
21
|
+
if files:
|
|
22
|
+
opened = {key: open(path, 'rb') for key, path in files.items()}
|
|
23
|
+
try:
|
|
24
|
+
resp = requests.post(url, data=kwargs, files=opened)
|
|
25
|
+
finally:
|
|
26
|
+
for f in opened.values():
|
|
27
|
+
f.close()
|
|
28
|
+
elif 'file' in kwargs:
|
|
29
|
+
with open(kwargs.pop('file'), 'rb') as f:
|
|
30
|
+
resp = requests.post(url, data=kwargs, files={'file': f})
|
|
31
|
+
else:
|
|
32
|
+
resp = requests.post(url, data=kwargs)
|
|
33
|
+
except requests.RequestException as e:
|
|
34
|
+
raise NetworkException(e) from e
|
|
35
|
+
|
|
36
|
+
if resp.status_code != 200:
|
|
37
|
+
raise NetworkException(f'bad response: {resp.status_code}')
|
|
38
|
+
|
|
39
|
+
text = resp.content.decode('utf-8')
|
|
40
|
+
if 'ERROR' in text:
|
|
41
|
+
raise ApiException(text)
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
def res(self, **kwargs):
|
|
45
|
+
try:
|
|
46
|
+
resp = requests.get(f'{self.base_url}/res.php', params=kwargs)
|
|
47
|
+
except requests.RequestException as e:
|
|
48
|
+
raise NetworkException(e) from e
|
|
49
|
+
|
|
50
|
+
if resp.status_code != 200:
|
|
51
|
+
raise NetworkException(f'bad response: {resp.status_code}')
|
|
52
|
+
|
|
53
|
+
text = resp.content.decode('utf-8')
|
|
54
|
+
if 'ERROR' in text:
|
|
55
|
+
raise ApiException(text)
|
|
56
|
+
return text
|
capskip/async_api.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from contextlib import AsyncExitStack
|
|
2
|
+
|
|
3
|
+
import aiofiles
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .exceptions import ApiException, NetworkException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncApiClient:
|
|
10
|
+
"""Async HTTP client for CapSkip in.php / res.php endpoints."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, host='127.0.0.1', port=8080):
|
|
13
|
+
self.host = host
|
|
14
|
+
self.port = port
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def base_url(self):
|
|
18
|
+
return f'http://{self.host}:{self.port}'
|
|
19
|
+
|
|
20
|
+
async def in_(self, files=None, **kwargs):
|
|
21
|
+
files = files or {}
|
|
22
|
+
url = f'{self.base_url}/in.php'
|
|
23
|
+
try:
|
|
24
|
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
25
|
+
if files:
|
|
26
|
+
async with AsyncExitStack() as stack:
|
|
27
|
+
file_objects = {}
|
|
28
|
+
for key, path in files.items():
|
|
29
|
+
handle = await stack.enter_async_context(aiofiles.open(path, 'rb'))
|
|
30
|
+
file_objects[key] = await handle.read()
|
|
31
|
+
resp = await client.post(url, data=kwargs, files=file_objects)
|
|
32
|
+
elif 'file' in kwargs:
|
|
33
|
+
path = kwargs.pop('file')
|
|
34
|
+
async with aiofiles.open(path, 'rb') as handle:
|
|
35
|
+
content = await handle.read()
|
|
36
|
+
resp = await client.post(url, data=kwargs, files={'file': content})
|
|
37
|
+
else:
|
|
38
|
+
resp = await client.post(url, data=kwargs)
|
|
39
|
+
except httpx.RequestError as e:
|
|
40
|
+
raise NetworkException(e) from e
|
|
41
|
+
|
|
42
|
+
if resp.status_code != 200:
|
|
43
|
+
raise NetworkException(f'bad response: {resp.status_code}')
|
|
44
|
+
|
|
45
|
+
text = resp.content.decode('utf-8')
|
|
46
|
+
if 'ERROR' in text:
|
|
47
|
+
raise ApiException(text)
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
async def res(self, **kwargs):
|
|
51
|
+
try:
|
|
52
|
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
53
|
+
resp = await client.get(f'{self.base_url}/res.php', params=kwargs)
|
|
54
|
+
except httpx.RequestError as e:
|
|
55
|
+
raise NetworkException(e) from e
|
|
56
|
+
|
|
57
|
+
if resp.status_code != 200:
|
|
58
|
+
raise NetworkException(f'bad response: {resp.status_code}')
|
|
59
|
+
|
|
60
|
+
text = resp.content.decode('utf-8')
|
|
61
|
+
if 'ERROR' in text:
|
|
62
|
+
raise ApiException(text)
|
|
63
|
+
return text
|
capskip/async_solver.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .async_api import AsyncApiClient
|
|
9
|
+
from ._api_params import apply_param_aliases, apply_proxy, prepare_submit_params
|
|
10
|
+
from .exceptions import ApiException, NetworkException, TimeoutException, ValidationException, SolverExceptions
|
|
11
|
+
from .solver import INITIAL_POLLING_INTERVAL, _apply_poll_result, _next_poll_interval, _parse_poll_response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncCapSkip:
|
|
15
|
+
"""Async client for the CapSkip local captcha solver."""
|
|
16
|
+
|
|
17
|
+
def __init__(self,
|
|
18
|
+
apiKey='capskip',
|
|
19
|
+
host='127.0.0.1',
|
|
20
|
+
port=8080,
|
|
21
|
+
defaultTimeout=120,
|
|
22
|
+
recaptchaTimeout=300,
|
|
23
|
+
pollingInterval=5):
|
|
24
|
+
|
|
25
|
+
self.API_KEY = apiKey
|
|
26
|
+
self.default_timeout = defaultTimeout
|
|
27
|
+
self.recaptcha_timeout = recaptchaTimeout
|
|
28
|
+
self.polling_interval = pollingInterval
|
|
29
|
+
self.api_client = AsyncApiClient(host=host, port=port)
|
|
30
|
+
self.exceptions = SolverExceptions
|
|
31
|
+
|
|
32
|
+
async def normal(self, file, **kwargs):
|
|
33
|
+
unsupported = set(kwargs) - {'json'}
|
|
34
|
+
if unsupported:
|
|
35
|
+
raise ValidationException(
|
|
36
|
+
f"Unsupported parameters for image captcha: {sorted(unsupported)}. "
|
|
37
|
+
f"Only json is supported besides the image input."
|
|
38
|
+
)
|
|
39
|
+
method = await self.get_method(file)
|
|
40
|
+
return await self.solve(**method, **kwargs)
|
|
41
|
+
|
|
42
|
+
async def recaptcha(self, sitekey, url, version='v2', enterprise=0, **kwargs):
|
|
43
|
+
params = {
|
|
44
|
+
'googlekey': sitekey,
|
|
45
|
+
'url': url,
|
|
46
|
+
'method': 'userrecaptcha',
|
|
47
|
+
'enterprise': enterprise,
|
|
48
|
+
**kwargs,
|
|
49
|
+
}
|
|
50
|
+
if str(version).lower() == 'v3':
|
|
51
|
+
params['version'] = 'v3'
|
|
52
|
+
return await self.solve(timeout=self.recaptcha_timeout, **params)
|
|
53
|
+
|
|
54
|
+
async def turnstile(self, sitekey, url, **kwargs):
|
|
55
|
+
return await self.solve(
|
|
56
|
+
sitekey=sitekey,
|
|
57
|
+
url=url,
|
|
58
|
+
method='turnstile',
|
|
59
|
+
poll_json=1,
|
|
60
|
+
**kwargs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs):
|
|
64
|
+
poll_json = int(kwargs.pop('poll_json', poll_json) or 0)
|
|
65
|
+
captcha_id = await self.send(**kwargs)
|
|
66
|
+
result = {'captchaId': captcha_id}
|
|
67
|
+
timeout = float(timeout or self.default_timeout)
|
|
68
|
+
sleep = float(polling_interval or self.polling_interval)
|
|
69
|
+
polled = await self.wait_result(captcha_id, timeout, sleep, json=poll_json)
|
|
70
|
+
return _apply_poll_result(result, polled)
|
|
71
|
+
|
|
72
|
+
async def wait_result(self, id_, timeout, polling_interval, json=0):
|
|
73
|
+
deadline = time.time() + timeout
|
|
74
|
+
interval = min(INITIAL_POLLING_INTERVAL, polling_interval)
|
|
75
|
+
while time.time() < deadline:
|
|
76
|
+
try:
|
|
77
|
+
return await self.get_result(id_, json=json)
|
|
78
|
+
except NetworkException:
|
|
79
|
+
await asyncio.sleep(interval)
|
|
80
|
+
interval = _next_poll_interval(interval, polling_interval)
|
|
81
|
+
raise TimeoutException(f'timeout {timeout} exceeded')
|
|
82
|
+
|
|
83
|
+
async def get_method(self, file):
|
|
84
|
+
if not file:
|
|
85
|
+
raise ValidationException('File required')
|
|
86
|
+
if file.startswith('data:'):
|
|
87
|
+
return {'method': 'base64', 'body': file.split(',', 1)[1]}
|
|
88
|
+
if '.' not in file and len(file) > 50:
|
|
89
|
+
return {'method': 'base64', 'body': file}
|
|
90
|
+
if file.startswith('http'):
|
|
91
|
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
92
|
+
resp = await client.get(file)
|
|
93
|
+
if resp.status_code != 200:
|
|
94
|
+
raise ValidationException(f'File could not be downloaded from url: {file}')
|
|
95
|
+
return {'method': 'base64', 'body': b64encode(resp.content).decode('utf-8')}
|
|
96
|
+
if not os.path.exists(file):
|
|
97
|
+
raise ValidationException(f'File not found: {file}')
|
|
98
|
+
return {'method': 'post', 'file': file}
|
|
99
|
+
|
|
100
|
+
async def send(self, **kwargs):
|
|
101
|
+
params = self._prepare_send_params({**kwargs, 'key': self.API_KEY})
|
|
102
|
+
files = params.pop('files', {})
|
|
103
|
+
response = await self.api_client.in_(files=files, **params)
|
|
104
|
+
if not response.startswith('OK|'):
|
|
105
|
+
raise ApiException(f'cannot recognize response {response}')
|
|
106
|
+
return response[3:]
|
|
107
|
+
|
|
108
|
+
async def get_result(self, id_, json=0):
|
|
109
|
+
query = {'key': self.API_KEY, 'action': 'get', 'id': id_}
|
|
110
|
+
if json:
|
|
111
|
+
query['json'] = 1
|
|
112
|
+
response = await self.api_client.res(**query)
|
|
113
|
+
return _parse_poll_response(response, json_mode=int(json or 0))
|
|
114
|
+
|
|
115
|
+
def _prepare_send_params(self, params: dict) -> dict:
|
|
116
|
+
method = params.get('method')
|
|
117
|
+
if method in ('post', 'base64'):
|
|
118
|
+
return prepare_submit_params(params, 'normal')
|
|
119
|
+
if method == 'userrecaptcha':
|
|
120
|
+
return prepare_submit_params(params, 'recaptcha', params.get('version', 'v2'))
|
|
121
|
+
if method == 'turnstile':
|
|
122
|
+
return prepare_submit_params(params, 'turnstile')
|
|
123
|
+
return apply_proxy(apply_param_aliases(params))
|
capskip/exceptions.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CapSkipError(Exception):
|
|
2
|
+
"""Base exception for all CapSkip SDK errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
SolverExceptions = CapSkipError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationException(CapSkipError):
|
|
9
|
+
"""Invalid or unsupported parameters."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NetworkException(CapSkipError):
|
|
13
|
+
"""Connection failure or captcha not ready."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiException(CapSkipError):
|
|
17
|
+
"""CapSkip API returned an error."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TimeoutException(CapSkipError):
|
|
21
|
+
"""Polling exceeded the configured timeout."""
|
capskip/solver.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .api import ApiClient
|
|
9
|
+
from ._api_params import apply_param_aliases, apply_proxy, prepare_submit_params
|
|
10
|
+
from .exceptions import ApiException, NetworkException, TimeoutException, ValidationException, SolverExceptions
|
|
11
|
+
|
|
12
|
+
# First poll fires this soon after submitting, then the interval backs off
|
|
13
|
+
# (doubling) up to the configured pollingInterval ceiling. Keeps latency low for
|
|
14
|
+
# fast local solves (e.g. image captchas) without hammering on slow ones.
|
|
15
|
+
INITIAL_POLLING_INTERVAL = 0.25
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _next_poll_interval(interval: float, ceiling: float) -> float:
|
|
19
|
+
return min(interval * 2, ceiling)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_poll_response(response: str, json_mode: int = 0):
|
|
23
|
+
if json_mode:
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(response)
|
|
26
|
+
except json.JSONDecodeError as exc:
|
|
27
|
+
raise ApiException(f'invalid JSON response: {response}') from exc
|
|
28
|
+
|
|
29
|
+
if data.get('status') == 0 and data.get('request') == 'CAPCHA_NOT_READY':
|
|
30
|
+
raise NetworkException
|
|
31
|
+
|
|
32
|
+
if data.get('status') != 1:
|
|
33
|
+
raise ApiException(f'cannot recognize response {data}')
|
|
34
|
+
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
if response == 'CAPCHA_NOT_READY':
|
|
38
|
+
raise NetworkException
|
|
39
|
+
|
|
40
|
+
if not response.startswith('OK|'):
|
|
41
|
+
raise ApiException(f'cannot recognize response {response}')
|
|
42
|
+
|
|
43
|
+
return response[3:]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _apply_poll_result(result: dict, polled) -> dict:
|
|
47
|
+
if isinstance(polled, dict):
|
|
48
|
+
result['code'] = polled.get('request', '')
|
|
49
|
+
user_agent = polled.get('useragent') or polled.get('userAgent')
|
|
50
|
+
if user_agent:
|
|
51
|
+
result['userAgent'] = user_agent
|
|
52
|
+
else:
|
|
53
|
+
result['code'] = polled
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CapSkip:
|
|
58
|
+
"""Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self,
|
|
61
|
+
apiKey='capskip',
|
|
62
|
+
host='127.0.0.1',
|
|
63
|
+
port=8080,
|
|
64
|
+
defaultTimeout=120,
|
|
65
|
+
recaptchaTimeout=300,
|
|
66
|
+
pollingInterval=5):
|
|
67
|
+
|
|
68
|
+
self.API_KEY = apiKey
|
|
69
|
+
self.default_timeout = defaultTimeout
|
|
70
|
+
self.recaptcha_timeout = recaptchaTimeout
|
|
71
|
+
self.polling_interval = pollingInterval
|
|
72
|
+
self.api_client = ApiClient(host=host, port=port)
|
|
73
|
+
self.exceptions = SolverExceptions
|
|
74
|
+
|
|
75
|
+
def normal(self, file, **kwargs):
|
|
76
|
+
unsupported = set(kwargs) - {'json'}
|
|
77
|
+
if unsupported:
|
|
78
|
+
raise ValidationException(
|
|
79
|
+
f"Unsupported parameters for image captcha: {sorted(unsupported)}. "
|
|
80
|
+
f"Only json is supported besides the image input."
|
|
81
|
+
)
|
|
82
|
+
return self.solve(**self.get_method(file), **kwargs)
|
|
83
|
+
|
|
84
|
+
def recaptcha(self, sitekey, url, version='v2', enterprise=0, **kwargs):
|
|
85
|
+
params = {
|
|
86
|
+
'googlekey': sitekey,
|
|
87
|
+
'url': url,
|
|
88
|
+
'method': 'userrecaptcha',
|
|
89
|
+
'enterprise': enterprise,
|
|
90
|
+
**kwargs,
|
|
91
|
+
}
|
|
92
|
+
if str(version).lower() == 'v3':
|
|
93
|
+
params['version'] = 'v3'
|
|
94
|
+
return self.solve(timeout=self.recaptcha_timeout, **params)
|
|
95
|
+
|
|
96
|
+
def turnstile(self, sitekey, url, **kwargs):
|
|
97
|
+
return self.solve(
|
|
98
|
+
sitekey=sitekey,
|
|
99
|
+
url=url,
|
|
100
|
+
method='turnstile',
|
|
101
|
+
poll_json=1,
|
|
102
|
+
**kwargs,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs):
|
|
106
|
+
poll_json = int(kwargs.pop('poll_json', poll_json) or 0)
|
|
107
|
+
captcha_id = self.send(**kwargs)
|
|
108
|
+
result = {'captchaId': captcha_id}
|
|
109
|
+
timeout = float(timeout or self.default_timeout)
|
|
110
|
+
sleep = float(polling_interval or self.polling_interval)
|
|
111
|
+
polled = self.wait_result(captcha_id, timeout, sleep, json=poll_json)
|
|
112
|
+
return _apply_poll_result(result, polled)
|
|
113
|
+
|
|
114
|
+
def wait_result(self, id_, timeout, polling_interval, json=0):
|
|
115
|
+
deadline = time.time() + timeout
|
|
116
|
+
interval = min(INITIAL_POLLING_INTERVAL, polling_interval)
|
|
117
|
+
while time.time() < deadline:
|
|
118
|
+
try:
|
|
119
|
+
return self.get_result(id_, json=json)
|
|
120
|
+
except NetworkException:
|
|
121
|
+
time.sleep(interval)
|
|
122
|
+
interval = _next_poll_interval(interval, polling_interval)
|
|
123
|
+
raise TimeoutException(f'timeout {timeout} exceeded')
|
|
124
|
+
|
|
125
|
+
def get_method(self, file):
|
|
126
|
+
if not file:
|
|
127
|
+
raise ValidationException('File required')
|
|
128
|
+
if file.startswith('data:'):
|
|
129
|
+
return {'method': 'base64', 'body': file.split(',', 1)[1]}
|
|
130
|
+
if '.' not in file and len(file) > 50:
|
|
131
|
+
return {'method': 'base64', 'body': file}
|
|
132
|
+
if file.startswith('http'):
|
|
133
|
+
resp = requests.get(file)
|
|
134
|
+
if resp.status_code != 200:
|
|
135
|
+
raise ValidationException(f'File could not be downloaded from url: {file}')
|
|
136
|
+
return {'method': 'base64', 'body': b64encode(resp.content).decode('utf-8')}
|
|
137
|
+
if not os.path.exists(file):
|
|
138
|
+
raise ValidationException(f'File not found: {file}')
|
|
139
|
+
return {'method': 'post', 'file': file}
|
|
140
|
+
|
|
141
|
+
def send(self, **kwargs):
|
|
142
|
+
params = self._prepare_send_params({**kwargs, 'key': self.API_KEY})
|
|
143
|
+
files = params.pop('files', {})
|
|
144
|
+
response = self.api_client.in_(files=files, **params)
|
|
145
|
+
if not response.startswith('OK|'):
|
|
146
|
+
raise ApiException(f'cannot recognize response {response}')
|
|
147
|
+
return response[3:]
|
|
148
|
+
|
|
149
|
+
def get_result(self, id_, json=0):
|
|
150
|
+
query = {'key': self.API_KEY, 'action': 'get', 'id': id_}
|
|
151
|
+
if json:
|
|
152
|
+
query['json'] = 1
|
|
153
|
+
return _parse_poll_response(self.api_client.res(**query), json_mode=int(json or 0))
|
|
154
|
+
|
|
155
|
+
def _prepare_send_params(self, params: dict) -> dict:
|
|
156
|
+
method = params.get('method')
|
|
157
|
+
if method in ('post', 'base64'):
|
|
158
|
+
return prepare_submit_params(params, 'normal')
|
|
159
|
+
if method == 'userrecaptcha':
|
|
160
|
+
return prepare_submit_params(params, 'recaptcha', params.get('version', 'v2'))
|
|
161
|
+
if method == 'turnstile':
|
|
162
|
+
return prepare_submit_params(params, 'turnstile')
|
|
163
|
+
return apply_proxy(apply_param_aliases(params))
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capskip
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the CapSkip local captcha solver
|
|
5
|
+
Author-email: CapSkip <support@capskip.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://capskip.com
|
|
8
|
+
Project-URL: Documentation, https://github.com/capskip/capskip-python/blob/main/docs/GETTING_STARTED.md
|
|
9
|
+
Project-URL: Repository, https://github.com/capskip/capskip-python
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/capskip/capskip-python/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/capskip/capskip-python/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: captcha,captcha-solver,recaptcha,cloudflare,turnstile,capskip,automation
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: requests>=2.28
|
|
26
|
+
Requires-Dist: httpx>=0.24
|
|
27
|
+
Requires-Dist: aiofiles>=23.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# CapSkip Python SDK
|
|
35
|
+
|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](https://github.com/capskip/capskip-python/actions/workflows/ci.yml)
|
|
39
|
+
|
|
40
|
+
Official Python client for the [CapSkip](https://capskip.com) **local** captcha solver.
|
|
41
|
+
|
|
42
|
+
CapSkip runs on your machine and exposes a standard captcha-solver HTTP API (the familiar `in.php` / `res.php` endpoints). This SDK wraps that API with clean, familiar method names, so you can solve captchas locally — no cloud service and no per-solve API fees beyond your CapSkip license.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Quick start (5 minutes)
|
|
47
|
+
|
|
48
|
+
### 1. Install CapSkip
|
|
49
|
+
|
|
50
|
+
Download and run the CapSkip desktop app from [capskip.com](https://capskip.com). Leave it running in the background.
|
|
51
|
+
|
|
52
|
+
In CapSkip settings, note:
|
|
53
|
+
|
|
54
|
+
- **API port** (default: `8080`)
|
|
55
|
+
- **API key** (optional — if validation is disabled, any string works)
|
|
56
|
+
|
|
57
|
+
### 2. Install the SDK
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install capskip
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or from source:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/capskip/capskip-python.git
|
|
67
|
+
cd capskip-python
|
|
68
|
+
pip install -e .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3. Solve your first captcha
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from capskip import CapSkip
|
|
75
|
+
|
|
76
|
+
solver = CapSkip(host="127.0.0.1", port=8080)
|
|
77
|
+
|
|
78
|
+
result = solver.recaptcha(
|
|
79
|
+
sitekey="YOUR_SITEKEY",
|
|
80
|
+
url="https://example.com/page-with-recaptcha",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
print(result["code"]) # g-recaptcha-response token
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **Prerequisite:** CapSkip must be running before you call the SDK. If you see a connection error, see [Troubleshooting](docs/TROUBLESHOOTING.md).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Supported captcha types
|
|
91
|
+
|
|
92
|
+
| Type | SDK method |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Image CAPTCHA (distorted text) | `solver.normal(file)` |
|
|
95
|
+
| reCAPTCHA v2 (checkbox) | `solver.recaptcha(sitekey, url)` |
|
|
96
|
+
| reCAPTCHA v2 Invisible | `solver.recaptcha(..., invisible=1)` |
|
|
97
|
+
| reCAPTCHA v2 Enterprise | `solver.recaptcha(..., enterprise=1)` |
|
|
98
|
+
| reCAPTCHA v3 | `solver.recaptcha(..., version="v3")` |
|
|
99
|
+
| reCAPTCHA v3 Enterprise | `solver.recaptcha(..., version="v3", enterprise=1)` |
|
|
100
|
+
| Cloudflare Turnstile (widget) | `solver.turnstile(sitekey, url)` |
|
|
101
|
+
| Cloudflare Turnstile (challenge page) | `solver.turnstile(..., data=..., pagedata=...)` |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Documentation
|
|
106
|
+
|
|
107
|
+
| Guide | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| [Tutorial](docs/TUTORIAL.md) | Complete walkthrough of every captcha type, sync and async |
|
|
110
|
+
| [Getting Started](docs/GETTING_STARTED.md) | Full setup: CapSkip app, SDK install, first script |
|
|
111
|
+
| [API Reference](docs/API_REFERENCE.md) | All classes, methods, parameters, and return values |
|
|
112
|
+
| [Examples](examples/) | Ready-to-run scripts for every captcha type |
|
|
113
|
+
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Connection errors, timeouts, proxy issues |
|
|
114
|
+
| [Contributing](CONTRIBUTING.md) | Development setup, tests, pull requests |
|
|
115
|
+
| [Changelog](CHANGELOG.md) | Release history |
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from capskip import CapSkip
|
|
123
|
+
|
|
124
|
+
solver = CapSkip(
|
|
125
|
+
apiKey="capskip", # your CapSkip API key (or any string if validation is off)
|
|
126
|
+
host="127.0.0.1", # CapSkip host
|
|
127
|
+
port=8080, # CapSkip port from app settings
|
|
128
|
+
defaultTimeout=120, # seconds — image captcha polling timeout
|
|
129
|
+
recaptchaTimeout=300, # seconds — reCAPTCHA / Turnstile polling timeout
|
|
130
|
+
pollingInterval=5, # max seconds between res.php polls (starts at 0.25s, backs off to this)
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use environment variables in production:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Linux / macOS
|
|
138
|
+
export CAPSKIP_API_KEY="your-key"
|
|
139
|
+
export CAPSKIP_HOST="127.0.0.1"
|
|
140
|
+
export CAPSKIP_PORT="8080"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```powershell
|
|
144
|
+
# Windows PowerShell
|
|
145
|
+
$env:CAPSKIP_API_KEY = "your-key"
|
|
146
|
+
$env:CAPSKIP_HOST = "127.0.0.1"
|
|
147
|
+
$env:CAPSKIP_PORT = "8080"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import os
|
|
152
|
+
from capskip import CapSkip
|
|
153
|
+
|
|
154
|
+
solver = CapSkip(
|
|
155
|
+
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
|
|
156
|
+
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
|
|
157
|
+
port=int(os.getenv("CAPSKIP_PORT", "8080")),
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Usage examples
|
|
164
|
+
|
|
165
|
+
### Image captcha
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
result = solver.normal("captcha.png")
|
|
169
|
+
result = solver.normal("https://example.com/captcha.jpg")
|
|
170
|
+
result = solver.normal("data:image/png;base64,iVBORw0KGgo...")
|
|
171
|
+
print(result["code"])
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### reCAPTCHA v2 / v3
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# reCAPTCHA v2
|
|
178
|
+
result = solver.recaptcha(sitekey="...", url="https://example.com")
|
|
179
|
+
|
|
180
|
+
# reCAPTCHA v3
|
|
181
|
+
result = solver.recaptcha(
|
|
182
|
+
sitekey="...",
|
|
183
|
+
url="https://example.com",
|
|
184
|
+
version="v3",
|
|
185
|
+
action="submit",
|
|
186
|
+
score=0.7,
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Cloudflare Turnstile
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
result = solver.turnstile(
|
|
194
|
+
sitekey="0x4AAAAAAA...",
|
|
195
|
+
url="https://example.com",
|
|
196
|
+
)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### With a proxy (reCAPTCHA & Turnstile only)
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# Proxy is not supported for image captcha
|
|
203
|
+
result = solver.recaptcha(
|
|
204
|
+
sitekey="...",
|
|
205
|
+
url="https://example.com",
|
|
206
|
+
proxy={"type": "HTTPS", "uri": "user:pass@1.2.3.4:3128"},
|
|
207
|
+
)
|
|
208
|
+
result = solver.turnstile(
|
|
209
|
+
sitekey="...",
|
|
210
|
+
url="https://example.com",
|
|
211
|
+
proxy={"type": "HTTP", "uri": "1.2.3.4:3128"},
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Async (parallel solving)
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
import asyncio
|
|
219
|
+
from capskip import AsyncCapSkip
|
|
220
|
+
|
|
221
|
+
async def main():
|
|
222
|
+
solver = AsyncCapSkip()
|
|
223
|
+
r1, r2 = await asyncio.gather(
|
|
224
|
+
solver.recaptcha(sitekey="...", url="https://a.com"),
|
|
225
|
+
solver.turnstile(sitekey="...", url="https://b.com"),
|
|
226
|
+
)
|
|
227
|
+
print(r1["code"], r2["code"])
|
|
228
|
+
|
|
229
|
+
asyncio.run(main())
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
More examples: [`examples/`](examples/)
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Return value
|
|
237
|
+
|
|
238
|
+
Every solve method returns:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
{
|
|
242
|
+
"captchaId": "12345", # internal ID from CapSkip
|
|
243
|
+
"code": "TOKEN_OR_TEXT" # solution — text for image, token for reCAPTCHA/Turnstile
|
|
244
|
+
"userAgent": "..." # Turnstile only — use when submitting challenge-page tokens
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Error handling
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from capskip import CapSkip, ValidationException, NetworkException, ApiException, TimeoutException
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = solver.recaptcha(sitekey="...", url="...")
|
|
257
|
+
except ValidationException:
|
|
258
|
+
pass # invalid parameters
|
|
259
|
+
except NetworkException:
|
|
260
|
+
pass # CapSkip not running, or captcha not ready (manual polling)
|
|
261
|
+
except ApiException:
|
|
262
|
+
pass # API returned an error code
|
|
263
|
+
except TimeoutException:
|
|
264
|
+
pass # polling timeout exceeded
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
git clone https://github.com/capskip/capskip-python.git
|
|
273
|
+
cd capskip-python
|
|
274
|
+
python -m venv .venv
|
|
275
|
+
|
|
276
|
+
# Windows
|
|
277
|
+
.venv\Scripts\activate
|
|
278
|
+
|
|
279
|
+
# Linux / macOS
|
|
280
|
+
source .venv/bin/activate
|
|
281
|
+
|
|
282
|
+
pip install -e ".[dev]"
|
|
283
|
+
pytest
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Links
|
|
291
|
+
|
|
292
|
+
- [CapSkip website](https://capskip.com)
|
|
293
|
+
- [CapSkip API docs](https://capskip.com/api-docs/)
|
|
294
|
+
- [Report an issue](https://github.com/capskip/capskip-python/issues)
|
|
295
|
+
- [PyPI package](https://pypi.org/project/capskip/) *(when published)*
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
capskip/__init__.py,sha256=oOVghk5aU1WoSbXXEyk7GiETRYsO3Y26inPec5BG-Ac,597
|
|
2
|
+
capskip/_api_params.py,sha256=zO3q-EEDxSPAYiKB0JkJSXRY1P2RRTdUYEYTSvmpvBA,3809
|
|
3
|
+
capskip/api.py,sha256=tvCvTmFyz8U0PayJ1Et_Z_cI0JqKb1PiswZZyptFATc,1807
|
|
4
|
+
capskip/async_api.py,sha256=7LgxNXPXlx0UdZ811KSNeC0Ua1qa7fiKYi_qyoX5DF0,2280
|
|
5
|
+
capskip/async_solver.py,sha256=bbAeO7aw67CVI3BTs7f46ip-rcSe1Adakiw-NfbjBR8,5087
|
|
6
|
+
capskip/exceptions.py,sha256=_dFHGjm_wc3DW1mpMtvuMiwDhpZyCL-U3gxRQDsE9d8,465
|
|
7
|
+
capskip/solver.py,sha256=WGeQ8pU3SxLfXhYfOP8hbIJLpfd2caE3GZmtcwGeMTY,6138
|
|
8
|
+
capskip-1.0.0.dist-info/licenses/LICENSE,sha256=hFVVci2vCCXeeP1G15tVusVHw_j2jiHM3WS9zEKjR1k,1064
|
|
9
|
+
capskip-1.0.0.dist-info/METADATA,sha256=dB-zDZXQhkYCNYMbRO6fFnAr730LcbD_xgivnOqxyMc,8251
|
|
10
|
+
capskip-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
capskip-1.0.0.dist-info/top_level.txt,sha256=_AHMAJcrez4g0s5jG_EON5kMwn9urg3mW-RgZxSvbas,8
|
|
12
|
+
capskip-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CapSkip
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capskip
|