linkshieldai 0.2.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.
- linkshieldai/__init__.py +26 -0
- linkshieldai/_transport.py +76 -0
- linkshieldai/_utils.py +122 -0
- linkshieldai/async_client.py +113 -0
- linkshieldai/cli.py +86 -0
- linkshieldai/client.py +113 -0
- linkshieldai/errors.py +57 -0
- linkshieldai/types.py +42 -0
- linkshieldai-0.2.0.dist-info/METADATA +255 -0
- linkshieldai-0.2.0.dist-info/RECORD +12 -0
- linkshieldai-0.2.0.dist-info/WHEEL +4 -0
- linkshieldai-0.2.0.dist-info/entry_points.txt +2 -0
linkshieldai/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .async_client import AsyncLinkShieldAI
|
|
2
|
+
from .client import LinkShieldAI
|
|
3
|
+
from .errors import (
|
|
4
|
+
APIConnectionError,
|
|
5
|
+
APIResponseError,
|
|
6
|
+
APIStatusError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
LinkShieldAIError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
)
|
|
11
|
+
from .types import BasicCheckResult, ChimeraResult, DetailedCheckResult, NSFWCheckResult
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIConnectionError",
|
|
15
|
+
"APIResponseError",
|
|
16
|
+
"APIStatusError",
|
|
17
|
+
"AsyncLinkShieldAI",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"BasicCheckResult",
|
|
20
|
+
"ChimeraResult",
|
|
21
|
+
"DetailedCheckResult",
|
|
22
|
+
"LinkShieldAI",
|
|
23
|
+
"LinkShieldAIError",
|
|
24
|
+
"NSFWCheckResult",
|
|
25
|
+
"RateLimitError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import APIConnectionError, APIResponseError, APIStatusError, RateLimitError, error_message_from_payload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
17
|
+
if not value:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return max(float(value), 0.0)
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_json_response(response: httpx.Response) -> dict[str, Any]:
|
|
26
|
+
try:
|
|
27
|
+
payload = response.json()
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
content_type = response.headers.get("content-type", "")
|
|
30
|
+
preview = response.text.strip().replace("\n", " ")[:300]
|
|
31
|
+
message = "LinkShieldAI API returned invalid JSON."
|
|
32
|
+
if preview:
|
|
33
|
+
message = f"{message} Content-Type: {content_type or 'unknown'}. Body preview: {preview}"
|
|
34
|
+
raise APIResponseError(message) from exc
|
|
35
|
+
if not isinstance(payload, dict):
|
|
36
|
+
raise APIResponseError("LinkShieldAI API returned a non-object JSON response.", payload=payload)
|
|
37
|
+
return payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle_status_error(response: httpx.Response) -> None:
|
|
41
|
+
if response.status_code < 400:
|
|
42
|
+
return
|
|
43
|
+
payload = None
|
|
44
|
+
try:
|
|
45
|
+
payload = response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
pass
|
|
48
|
+
if response.status_code == 429:
|
|
49
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
50
|
+
raise RateLimitError(retry_after=retry_after)
|
|
51
|
+
message = error_message_from_payload(payload, f"LinkShieldAI API returned HTTP {response.status_code}.")
|
|
52
|
+
raise APIStatusError(message, status_code=response.status_code, response=response, payload=payload)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def retry_delay(attempt: int, backoff_factor: float, response: httpx.Response | None = None) -> float:
|
|
56
|
+
if response is not None:
|
|
57
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
58
|
+
if retry_after is not None:
|
|
59
|
+
return retry_after
|
|
60
|
+
return backoff_factor * (2 ** attempt)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def should_retry_response(response: httpx.Response, attempt: int, max_retries: int) -> bool:
|
|
64
|
+
return attempt < max_retries and response.status_code in RETRYABLE_STATUS_CODES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def should_retry_exception(exc: httpx.RequestError, attempt: int, max_retries: int) -> bool:
|
|
68
|
+
return attempt < max_retries and isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.PoolTimeout))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def log_request(logger: logging.Logger, method: str, url: str) -> None:
|
|
72
|
+
logger.debug("LinkShieldAI request: %s %s", method, url)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def log_retry(logger: logging.Logger, attempt: int, delay: float, reason: str) -> None:
|
|
76
|
+
logger.debug("LinkShieldAI retry %s in %.2fs: %s", attempt + 1, delay, reason)
|
linkshieldai/_utils.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from .errors import APIResponseError, AuthenticationError, error_message_from_payload
|
|
9
|
+
from .types import BasicCheckResult, ChimeraResult, DetailedCheckResult, NSFWCheckResult, RawJSON
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.linkshieldai.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_base_url(base_url: str) -> str:
|
|
16
|
+
return base_url.rstrip("/")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_api_key(api_key: str | None) -> str:
|
|
20
|
+
resolved = api_key or os.getenv("LINKSHIELDAI_API_KEY")
|
|
21
|
+
if not resolved:
|
|
22
|
+
raise AuthenticationError(
|
|
23
|
+
"LinkShieldAI API key is required. Pass api_key=... or set LINKSHIELDAI_API_KEY."
|
|
24
|
+
)
|
|
25
|
+
return resolved
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_json_payload(payload: Any) -> RawJSON:
|
|
29
|
+
if not isinstance(payload, dict):
|
|
30
|
+
raise APIResponseError("LinkShieldAI API returned a non-object JSON response.", payload=payload)
|
|
31
|
+
if payload.get("Error") or payload.get("error"):
|
|
32
|
+
raise APIResponseError(error_message_from_payload(payload), payload=payload)
|
|
33
|
+
return payload
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_malicious_text(result: Any) -> bool:
|
|
37
|
+
value = str(result or "").strip().lower()
|
|
38
|
+
safe_phrases = ("didn't detect", "did not detect", "no malicious", "not malicious")
|
|
39
|
+
if any(phrase in value for phrase in safe_phrases):
|
|
40
|
+
return False
|
|
41
|
+
return value in {"might be malicious", "malicious", "true"} or "malicious" in value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_safe_text(result: Any) -> bool:
|
|
45
|
+
value = str(result or "").strip().lower()
|
|
46
|
+
return value in {
|
|
47
|
+
"likely safe",
|
|
48
|
+
"the system didn't detect anything malicious.",
|
|
49
|
+
"false",
|
|
50
|
+
"benign",
|
|
51
|
+
} or "safe" in value or "benign" in value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_basic(payload: Mapping[str, Any]) -> BasicCheckResult:
|
|
55
|
+
raw = dict(payload)
|
|
56
|
+
result = raw.get("result")
|
|
57
|
+
return BasicCheckResult(
|
|
58
|
+
result=str(result) if result is not None else None,
|
|
59
|
+
is_malicious=is_malicious_text(result),
|
|
60
|
+
is_safe=is_safe_text(result),
|
|
61
|
+
raw=raw,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_detailed(payload: Mapping[str, Any]) -> DetailedCheckResult:
|
|
66
|
+
raw = dict(payload)
|
|
67
|
+
result = raw.get("result")
|
|
68
|
+
screenshot_url = raw.get("screenshot url") or raw.get("screenshot_url")
|
|
69
|
+
tag = raw.get("tag")
|
|
70
|
+
return DetailedCheckResult(
|
|
71
|
+
result=str(result) if result is not None else None,
|
|
72
|
+
screenshot_url=str(screenshot_url) if screenshot_url else None,
|
|
73
|
+
tag=str(tag) if tag is not None else None,
|
|
74
|
+
is_malicious=is_malicious_text(result),
|
|
75
|
+
raw=raw,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_nsfw(payload: Mapping[str, Any]) -> NSFWCheckResult:
|
|
80
|
+
raw = dict(payload)
|
|
81
|
+
result = raw.get("result")
|
|
82
|
+
is_nsfw = str(result or "").strip().lower() == "true"
|
|
83
|
+
return NSFWCheckResult(
|
|
84
|
+
result=str(result) if result is not None else None,
|
|
85
|
+
is_nsfw=is_nsfw,
|
|
86
|
+
raw=raw,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_chimera(payload: Mapping[str, Any]) -> ChimeraResult:
|
|
91
|
+
raw = dict(payload)
|
|
92
|
+
result = raw.get("result")
|
|
93
|
+
probability = raw.get("probability")
|
|
94
|
+
matched_signatures = raw.get("matched_signatures")
|
|
95
|
+
try:
|
|
96
|
+
probability_value = float(probability) if probability is not None else None
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
probability_value = None
|
|
99
|
+
try:
|
|
100
|
+
matched_value = int(matched_signatures) if matched_signatures is not None else None
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
matched_value = None
|
|
103
|
+
return ChimeraResult(
|
|
104
|
+
result=str(result) if result is not None else None,
|
|
105
|
+
probability=probability_value,
|
|
106
|
+
detection_method=str(raw["detection_method"]) if raw.get("detection_method") is not None else None,
|
|
107
|
+
matched_signatures=matched_value,
|
|
108
|
+
url=str(raw["url"]) if raw.get("url") is not None else None,
|
|
109
|
+
is_malicious=is_malicious_text(result),
|
|
110
|
+
raw=raw,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def screenshot_file_name(file_name_or_url: str) -> str:
|
|
115
|
+
parsed = urlparse(file_name_or_url)
|
|
116
|
+
if parsed.scheme and parsed.path:
|
|
117
|
+
candidate = Path(parsed.path).name
|
|
118
|
+
else:
|
|
119
|
+
candidate = Path(file_name_or_url).name
|
|
120
|
+
if not candidate:
|
|
121
|
+
raise ValueError("A screenshot file name or screenshot URL is required.")
|
|
122
|
+
return candidate
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._utils import (
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
normalize_base_url,
|
|
12
|
+
parse_basic,
|
|
13
|
+
parse_chimera,
|
|
14
|
+
parse_detailed,
|
|
15
|
+
parse_nsfw,
|
|
16
|
+
resolve_api_key,
|
|
17
|
+
screenshot_file_name,
|
|
18
|
+
)
|
|
19
|
+
from ._transport import handle_status_error, log_request, log_retry, parse_json_response, retry_delay, should_retry_exception, should_retry_response
|
|
20
|
+
from .errors import APIConnectionError
|
|
21
|
+
from .types import BasicCheckResult, ChimeraResult, DetailedCheckResult, NSFWCheckResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncLinkShieldAI:
|
|
25
|
+
"""Asynchronous client for the LinkShieldAI API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
*,
|
|
31
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
32
|
+
timeout: float | httpx.Timeout = 10.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
backoff_factor: float = 0.5,
|
|
35
|
+
logger: logging.Logger | None = None,
|
|
36
|
+
client: httpx.AsyncClient | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.api_key = resolve_api_key(api_key)
|
|
39
|
+
self.base_url = normalize_base_url(base_url)
|
|
40
|
+
self.max_retries = max(0, max_retries)
|
|
41
|
+
self.backoff_factor = max(0.0, backoff_factor)
|
|
42
|
+
self.logger = logger or logging.getLogger("linkshieldai")
|
|
43
|
+
self._owns_client = client is None
|
|
44
|
+
self._client = client or httpx.AsyncClient(timeout=timeout)
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
if self._owns_client:
|
|
48
|
+
await self._client.aclose()
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> "AsyncLinkShieldAI":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __aexit__(self, *args: object) -> None:
|
|
54
|
+
await self.close()
|
|
55
|
+
|
|
56
|
+
async def basic_check(self, url: str) -> BasicCheckResult:
|
|
57
|
+
payload = await self._get_json("/", params={"key": self.api_key, "url": url})
|
|
58
|
+
return parse_basic(payload)
|
|
59
|
+
|
|
60
|
+
async def detailed_check(self, url: str) -> DetailedCheckResult:
|
|
61
|
+
payload = await self._get_json("/classify_link", params={"key": self.api_key, "url": url})
|
|
62
|
+
return parse_detailed(payload)
|
|
63
|
+
|
|
64
|
+
async def nsfw_check(self, url: str) -> NSFWCheckResult:
|
|
65
|
+
payload = await self._get_json("/nsfw/site", params={"key": self.api_key, "url": url})
|
|
66
|
+
return parse_nsfw(payload)
|
|
67
|
+
|
|
68
|
+
async def chimera(self, url: str) -> ChimeraResult:
|
|
69
|
+
payload = await self._get_json("/chimera", params={"key": self.api_key, "url": url})
|
|
70
|
+
return parse_chimera(payload)
|
|
71
|
+
|
|
72
|
+
async def is_malicious(self, url: str) -> bool:
|
|
73
|
+
return (await self.basic_check(url)).is_malicious
|
|
74
|
+
|
|
75
|
+
async def is_nsfw(self, url: str) -> bool:
|
|
76
|
+
return (await self.nsfw_check(url)).is_nsfw
|
|
77
|
+
|
|
78
|
+
async def get_screenshot(self, file_name_or_url: str, output_path: str | Path | None = None) -> bytes:
|
|
79
|
+
file_name = screenshot_file_name(file_name_or_url)
|
|
80
|
+
response = await self._request("GET", f"/screenshot/{file_name}")
|
|
81
|
+
content = response.content
|
|
82
|
+
if output_path is not None:
|
|
83
|
+
Path(output_path).write_bytes(content)
|
|
84
|
+
return content
|
|
85
|
+
|
|
86
|
+
async def _get_json(self, path: str, *, params: dict[str, object]) -> dict[str, object]:
|
|
87
|
+
response = await self._request("GET", path, params=params)
|
|
88
|
+
payload = parse_json_response(response)
|
|
89
|
+
from ._utils import ensure_json_payload
|
|
90
|
+
|
|
91
|
+
return ensure_json_payload(payload)
|
|
92
|
+
|
|
93
|
+
async def _request(self, method: str, path: str, **kwargs: object) -> httpx.Response:
|
|
94
|
+
url = f"{self.base_url}{path}"
|
|
95
|
+
for attempt in range(self.max_retries + 1):
|
|
96
|
+
log_request(self.logger, method, url)
|
|
97
|
+
try:
|
|
98
|
+
response = await self._client.request(method, url, **kwargs)
|
|
99
|
+
except httpx.RequestError as exc:
|
|
100
|
+
if should_retry_exception(exc, attempt, self.max_retries):
|
|
101
|
+
delay = retry_delay(attempt, self.backoff_factor)
|
|
102
|
+
log_retry(self.logger, attempt, delay, str(exc))
|
|
103
|
+
await asyncio.sleep(delay)
|
|
104
|
+
continue
|
|
105
|
+
raise APIConnectionError(f"Could not connect to LinkShieldAI API: {exc}") from exc
|
|
106
|
+
if should_retry_response(response, attempt, self.max_retries):
|
|
107
|
+
delay = retry_delay(attempt, self.backoff_factor, response)
|
|
108
|
+
log_retry(self.logger, attempt, delay, f"HTTP {response.status_code}")
|
|
109
|
+
await asyncio.sleep(delay)
|
|
110
|
+
continue
|
|
111
|
+
handle_status_error(response)
|
|
112
|
+
return response
|
|
113
|
+
raise APIConnectionError("Could not connect to LinkShieldAI API after retries.")
|
linkshieldai/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .client import LinkShieldAI
|
|
11
|
+
from .errors import LinkShieldAIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def result_to_jsonable(value: Any) -> Any:
|
|
15
|
+
if dataclasses.is_dataclass(value):
|
|
16
|
+
return dataclasses.asdict(value)
|
|
17
|
+
if isinstance(value, bytes):
|
|
18
|
+
return {"bytes": len(value)}
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="linkshieldai", description="Command line client for the LinkShieldAI API.")
|
|
24
|
+
parser.add_argument("--api-key", help="LinkShieldAI API key. Defaults to LINKSHIELDAI_API_KEY.")
|
|
25
|
+
parser.add_argument("--base-url", default="https://api.linkshieldai.com", help="API base URL.")
|
|
26
|
+
parser.add_argument("--timeout", type=float, default=10.0, help="Request timeout in seconds.")
|
|
27
|
+
parser.add_argument("--max-retries", type=int, default=2, help="Retry attempts for transient failures.")
|
|
28
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging.")
|
|
29
|
+
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
basic = subparsers.add_parser("basic", help="Run a basic URL safety check.")
|
|
33
|
+
basic.add_argument("url")
|
|
34
|
+
|
|
35
|
+
detailed = subparsers.add_parser("detailed", help="Run a detailed URL safety check.")
|
|
36
|
+
detailed.add_argument("url")
|
|
37
|
+
|
|
38
|
+
nsfw = subparsers.add_parser("nsfw", help="Run an NSFW URL check.")
|
|
39
|
+
nsfw.add_argument("url")
|
|
40
|
+
|
|
41
|
+
chimera = subparsers.add_parser("chimera", help="Run Chimera AI classification.")
|
|
42
|
+
chimera.add_argument("url")
|
|
43
|
+
|
|
44
|
+
screenshot = subparsers.add_parser("screenshot", help="Download a screenshot by file name or screenshot URL.")
|
|
45
|
+
screenshot.add_argument("file_name_or_url")
|
|
46
|
+
screenshot.add_argument("--output", "-o", required=True, help="Output image path.")
|
|
47
|
+
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
parser = build_parser()
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with LinkShieldAI(
|
|
58
|
+
api_key=args.api_key,
|
|
59
|
+
base_url=args.base_url,
|
|
60
|
+
timeout=args.timeout,
|
|
61
|
+
max_retries=args.max_retries,
|
|
62
|
+
) as client:
|
|
63
|
+
if args.command == "basic":
|
|
64
|
+
result = client.basic_check(args.url)
|
|
65
|
+
elif args.command == "detailed":
|
|
66
|
+
result = client.detailed_check(args.url)
|
|
67
|
+
elif args.command == "nsfw":
|
|
68
|
+
result = client.nsfw_check(args.url)
|
|
69
|
+
elif args.command == "chimera":
|
|
70
|
+
result = client.chimera(args.url)
|
|
71
|
+
elif args.command == "screenshot":
|
|
72
|
+
client.get_screenshot(args.file_name_or_url, args.output)
|
|
73
|
+
result = {"saved": args.output}
|
|
74
|
+
else:
|
|
75
|
+
parser.error(f"Unknown command: {args.command}")
|
|
76
|
+
return 2
|
|
77
|
+
except LinkShieldAIError as exc:
|
|
78
|
+
print(json.dumps({"error": str(exc)}, indent=2), file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
print(json.dumps(result_to_jsonable(result), indent=2))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
linkshieldai/client.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._utils import (
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
normalize_base_url,
|
|
12
|
+
parse_basic,
|
|
13
|
+
parse_chimera,
|
|
14
|
+
parse_detailed,
|
|
15
|
+
parse_nsfw,
|
|
16
|
+
resolve_api_key,
|
|
17
|
+
screenshot_file_name,
|
|
18
|
+
)
|
|
19
|
+
from ._transport import handle_status_error, log_request, log_retry, parse_json_response, retry_delay, should_retry_exception, should_retry_response
|
|
20
|
+
from .errors import APIConnectionError
|
|
21
|
+
from .types import BasicCheckResult, ChimeraResult, DetailedCheckResult, NSFWCheckResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LinkShieldAI:
|
|
25
|
+
"""Synchronous client for the LinkShieldAI API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
*,
|
|
31
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
32
|
+
timeout: float | httpx.Timeout = 10.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
backoff_factor: float = 0.5,
|
|
35
|
+
logger: logging.Logger | None = None,
|
|
36
|
+
client: httpx.Client | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.api_key = resolve_api_key(api_key)
|
|
39
|
+
self.base_url = normalize_base_url(base_url)
|
|
40
|
+
self.max_retries = max(0, max_retries)
|
|
41
|
+
self.backoff_factor = max(0.0, backoff_factor)
|
|
42
|
+
self.logger = logger or logging.getLogger("linkshieldai")
|
|
43
|
+
self._owns_client = client is None
|
|
44
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
if self._owns_client:
|
|
48
|
+
self._client.close()
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> "LinkShieldAI":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *args: object) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
def basic_check(self, url: str) -> BasicCheckResult:
|
|
57
|
+
payload = self._get_json("/", params={"key": self.api_key, "url": url})
|
|
58
|
+
return parse_basic(payload)
|
|
59
|
+
|
|
60
|
+
def detailed_check(self, url: str) -> DetailedCheckResult:
|
|
61
|
+
payload = self._get_json("/classify_link", params={"key": self.api_key, "url": url})
|
|
62
|
+
return parse_detailed(payload)
|
|
63
|
+
|
|
64
|
+
def nsfw_check(self, url: str) -> NSFWCheckResult:
|
|
65
|
+
payload = self._get_json("/nsfw/site", params={"key": self.api_key, "url": url})
|
|
66
|
+
return parse_nsfw(payload)
|
|
67
|
+
|
|
68
|
+
def chimera(self, url: str) -> ChimeraResult:
|
|
69
|
+
payload = self._get_json("/chimera", params={"key": self.api_key, "url": url})
|
|
70
|
+
return parse_chimera(payload)
|
|
71
|
+
|
|
72
|
+
def is_malicious(self, url: str) -> bool:
|
|
73
|
+
return self.basic_check(url).is_malicious
|
|
74
|
+
|
|
75
|
+
def is_nsfw(self, url: str) -> bool:
|
|
76
|
+
return self.nsfw_check(url).is_nsfw
|
|
77
|
+
|
|
78
|
+
def get_screenshot(self, file_name_or_url: str, output_path: str | Path | None = None) -> bytes:
|
|
79
|
+
file_name = screenshot_file_name(file_name_or_url)
|
|
80
|
+
response = self._request("GET", f"/screenshot/{file_name}")
|
|
81
|
+
content = response.content
|
|
82
|
+
if output_path is not None:
|
|
83
|
+
Path(output_path).write_bytes(content)
|
|
84
|
+
return content
|
|
85
|
+
|
|
86
|
+
def _get_json(self, path: str, *, params: dict[str, object]) -> dict[str, object]:
|
|
87
|
+
response = self._request("GET", path, params=params)
|
|
88
|
+
payload = parse_json_response(response)
|
|
89
|
+
from ._utils import ensure_json_payload
|
|
90
|
+
|
|
91
|
+
return ensure_json_payload(payload)
|
|
92
|
+
|
|
93
|
+
def _request(self, method: str, path: str, **kwargs: object) -> httpx.Response:
|
|
94
|
+
url = f"{self.base_url}{path}"
|
|
95
|
+
for attempt in range(self.max_retries + 1):
|
|
96
|
+
log_request(self.logger, method, url)
|
|
97
|
+
try:
|
|
98
|
+
response = self._client.request(method, url, **kwargs)
|
|
99
|
+
except httpx.RequestError as exc:
|
|
100
|
+
if should_retry_exception(exc, attempt, self.max_retries):
|
|
101
|
+
delay = retry_delay(attempt, self.backoff_factor)
|
|
102
|
+
log_retry(self.logger, attempt, delay, str(exc))
|
|
103
|
+
time.sleep(delay)
|
|
104
|
+
continue
|
|
105
|
+
raise APIConnectionError(f"Could not connect to LinkShieldAI API: {exc}") from exc
|
|
106
|
+
if should_retry_response(response, attempt, self.max_retries):
|
|
107
|
+
delay = retry_delay(attempt, self.backoff_factor, response)
|
|
108
|
+
log_retry(self.logger, attempt, delay, f"HTTP {response.status_code}")
|
|
109
|
+
time.sleep(delay)
|
|
110
|
+
continue
|
|
111
|
+
handle_status_error(response)
|
|
112
|
+
return response
|
|
113
|
+
raise APIConnectionError("Could not connect to LinkShieldAI API after retries.")
|
linkshieldai/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LinkShieldAIError(Exception):
|
|
7
|
+
"""Base exception for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(LinkShieldAIError):
|
|
11
|
+
"""Raised when an API key is missing or rejected."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitError(LinkShieldAIError):
|
|
15
|
+
"""Raised when the API reports a rate limit response."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str = "LinkShieldAI API rate limit exceeded.", *, retry_after: float | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.retry_after = retry_after
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIConnectionError(LinkShieldAIError):
|
|
23
|
+
"""Raised when the SDK cannot connect to the API."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class APIStatusError(LinkShieldAIError):
|
|
27
|
+
"""Raised for non-success HTTP responses."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
status_code: int,
|
|
34
|
+
response: Any | None = None,
|
|
35
|
+
payload: Any | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
self.response = response
|
|
40
|
+
self.payload = payload
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APIResponseError(LinkShieldAIError):
|
|
44
|
+
"""Raised when the API returns a JSON error payload."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, payload: Any | None = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.payload = payload
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def error_message_from_payload(payload: Any, fallback: Optional[str] = None) -> str:
|
|
52
|
+
if isinstance(payload, dict):
|
|
53
|
+
for key in ("Error", "error", "message", "detail"):
|
|
54
|
+
value = payload.get(key)
|
|
55
|
+
if value:
|
|
56
|
+
return str(value)
|
|
57
|
+
return fallback or "LinkShieldAI API request failed."
|
linkshieldai/types.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
RawJSON = Dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class BasicCheckResult:
|
|
12
|
+
result: Optional[str]
|
|
13
|
+
is_malicious: bool
|
|
14
|
+
is_safe: bool
|
|
15
|
+
raw: RawJSON
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DetailedCheckResult:
|
|
20
|
+
result: Optional[str]
|
|
21
|
+
screenshot_url: Optional[str]
|
|
22
|
+
tag: Optional[str]
|
|
23
|
+
is_malicious: bool
|
|
24
|
+
raw: RawJSON
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class NSFWCheckResult:
|
|
29
|
+
result: Optional[str]
|
|
30
|
+
is_nsfw: bool
|
|
31
|
+
raw: RawJSON
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ChimeraResult:
|
|
36
|
+
result: Optional[str]
|
|
37
|
+
probability: Optional[float]
|
|
38
|
+
detection_method: Optional[str]
|
|
39
|
+
matched_signatures: Optional[int]
|
|
40
|
+
url: Optional[str]
|
|
41
|
+
is_malicious: bool
|
|
42
|
+
raw: RawJSON
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkshieldai
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the LinkShieldAI URL safety API.
|
|
5
|
+
Project-URL: Homepage, https://linkshieldai.com
|
|
6
|
+
Project-URL: Documentation, https://docs.linkshieldai.com
|
|
7
|
+
Author: LinkShieldAI
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: linkshieldai,phishing,sdk,security,url-safety
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<1,>=0.27.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine>=5.0.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# LinkShieldAI Python SDK
|
|
29
|
+
|
|
30
|
+
Python wrapper for the LinkShieldAI API at `https://api.linkshieldai.com`.
|
|
31
|
+
|
|
32
|
+
The SDK supports:
|
|
33
|
+
|
|
34
|
+
- Basic URL safety checks
|
|
35
|
+
- Detailed checks with screenshot URL and detected tag
|
|
36
|
+
- Screenshot download
|
|
37
|
+
- NSFW site checks
|
|
38
|
+
- Chimera AI classification
|
|
39
|
+
- Sync and async clients
|
|
40
|
+
- Retry/backoff for transient API failures
|
|
41
|
+
- A small command-line tool
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
From this folder:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For tests:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install -e ".[dev]"
|
|
55
|
+
python -m pytest
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Authentication
|
|
59
|
+
|
|
60
|
+
The API uses a query parameter named `key`.
|
|
61
|
+
|
|
62
|
+
Pass the API key directly:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from linkshieldai import LinkShieldAI
|
|
66
|
+
|
|
67
|
+
client = LinkShieldAI(api_key="YOUR_API_KEY")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or set an environment variable:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
$env:LINKSHIELDAI_API_KEY = "YOUR_API_KEY"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from linkshieldai import LinkShieldAI
|
|
78
|
+
|
|
79
|
+
client = LinkShieldAI()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Basic Check
|
|
83
|
+
|
|
84
|
+
Wraps:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
GET https://api.linkshieldai.com/?key={key}&url={url}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from linkshieldai import LinkShieldAI
|
|
92
|
+
|
|
93
|
+
client = LinkShieldAI()
|
|
94
|
+
result = client.basic_check("https://example.com")
|
|
95
|
+
|
|
96
|
+
print(result.result)
|
|
97
|
+
print(result.is_malicious)
|
|
98
|
+
print(result.raw)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Detailed Check
|
|
102
|
+
|
|
103
|
+
Wraps:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
GET https://api.linkshieldai.com/classify_link?key={key}&url={url}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
result = client.detailed_check("https://example.com")
|
|
111
|
+
|
|
112
|
+
print(result.result)
|
|
113
|
+
print(result.screenshot_url)
|
|
114
|
+
print(result.tag)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The API field `"screenshot url"` is normalized to `screenshot_url`.
|
|
118
|
+
|
|
119
|
+
## Download Screenshot
|
|
120
|
+
|
|
121
|
+
Wraps:
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
GET https://api.linkshieldai.com/screenshot/{file_name}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
image_bytes = client.get_screenshot("05046f.png")
|
|
129
|
+
client.get_screenshot("https://api.linkshieldai.com/screenshot/05046f.png", "site.png")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## NSFW Check
|
|
133
|
+
|
|
134
|
+
Wraps:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
GET https://api.linkshieldai.com/nsfw/site?key={key}&url={url}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
result = client.nsfw_check("https://example.com")
|
|
142
|
+
print(result.is_nsfw)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Chimera Check
|
|
146
|
+
|
|
147
|
+
Wraps:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
GET https://api.linkshieldai.com/chimera?key={key}&url={url}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
result = client.chimera("https://google.com")
|
|
155
|
+
|
|
156
|
+
print(result.result)
|
|
157
|
+
print(result.probability)
|
|
158
|
+
print(result.detection_method)
|
|
159
|
+
print(result.matched_signatures)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Async Usage
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import asyncio
|
|
166
|
+
from linkshieldai import AsyncLinkShieldAI
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def main():
|
|
170
|
+
async with AsyncLinkShieldAI() as client:
|
|
171
|
+
result = await client.chimera("https://google.com")
|
|
172
|
+
print(result.result, result.probability)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
asyncio.run(main())
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Custom API Host
|
|
179
|
+
|
|
180
|
+
The default host is:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
https://api.linkshieldai.com
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
You can override it for staging or testing:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
client = LinkShieldAI(base_url="https://api.linkshieldai.com")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Timeouts, Retries, and Logging
|
|
193
|
+
|
|
194
|
+
By default the SDK uses:
|
|
195
|
+
|
|
196
|
+
- `timeout=10.0`
|
|
197
|
+
- `max_retries=2`
|
|
198
|
+
- `backoff_factor=0.5`
|
|
199
|
+
|
|
200
|
+
Retries are applied to temporary connection failures and HTTP `429`, `502`, `503`, and `504`.
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import logging
|
|
204
|
+
from linkshieldai import LinkShieldAI
|
|
205
|
+
|
|
206
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
207
|
+
|
|
208
|
+
client = LinkShieldAI(
|
|
209
|
+
timeout=15.0,
|
|
210
|
+
max_retries=3,
|
|
211
|
+
backoff_factor=1.0,
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## CLI
|
|
216
|
+
|
|
217
|
+
After installation, use:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
linkshieldai --api-key YOUR_API_KEY basic https://example.com
|
|
221
|
+
linkshieldai --api-key YOUR_API_KEY detailed https://example.com
|
|
222
|
+
linkshieldai --api-key YOUR_API_KEY nsfw https://example.com
|
|
223
|
+
linkshieldai --api-key YOUR_API_KEY chimera https://google.com
|
|
224
|
+
linkshieldai --api-key YOUR_API_KEY screenshot 05046f.png --output site.png
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
You can omit `--api-key` if `LINKSHIELDAI_API_KEY` is set.
|
|
228
|
+
|
|
229
|
+
## Errors
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from linkshieldai import (
|
|
233
|
+
APIConnectionError,
|
|
234
|
+
APIResponseError,
|
|
235
|
+
APIStatusError,
|
|
236
|
+
AuthenticationError,
|
|
237
|
+
RateLimitError,
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `AuthenticationError`: missing API key.
|
|
242
|
+
- `RateLimitError`: HTTP 429.
|
|
243
|
+
- `APIStatusError`: non-success HTTP status.
|
|
244
|
+
- `APIResponseError`: malformed JSON or API payload with `Error` / `error`.
|
|
245
|
+
- `APIConnectionError`: timeout, DNS, or connection failure.
|
|
246
|
+
|
|
247
|
+
Raw API payloads are preserved on result objects through `.raw`.
|
|
248
|
+
|
|
249
|
+
## Production Notes
|
|
250
|
+
|
|
251
|
+
- Keep API keys server-side. Do not expose them in browser JavaScript.
|
|
252
|
+
- Use `max_retries` with a small non-zero value for bots, moderation pipelines, and web apps.
|
|
253
|
+
- Catch `RateLimitError` when running near the documented limits.
|
|
254
|
+
- Tests are mocked by default and do not call the live API.
|
|
255
|
+
- For live smoke tests, set `LINKSHIELDAI_API_KEY` and call the examples manually.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
linkshieldai/__init__.py,sha256=cTxC4q2jNRR5uiUUDiYnmp-6MGCBIvSGuAPrmtqbHKE,625
|
|
2
|
+
linkshieldai/_transport.py,sha256=LyCnfwVGcgiC_WL2zdFYqFDY0jJ8q-BOnT19Y3FCU20,2759
|
|
3
|
+
linkshieldai/_utils.py,sha256=XqKx8GUIpIL5kTxTYL6xI645qe2WLA8_qj02twaV4pA,4182
|
|
4
|
+
linkshieldai/async_client.py,sha256=iWkR8sTmT8notU8e_vTrQ3NX2-VlkPzxWp4_VV-OrQU,4544
|
|
5
|
+
linkshieldai/cli.py,sha256=tXKhAM75Hac3PbSYBZZGVvW2YsBovRpqsoS_7YUIZX0,3197
|
|
6
|
+
linkshieldai/client.py,sha256=YKd3RvLN0ich4Vqt2IDWAQlcB935T_MnPGKMnmvGNwM,4357
|
|
7
|
+
linkshieldai/errors.py,sha256=4zxfgMPqXOGtXhJNFthFtUXez6NJX3XwWKu9BLrgzSk,1653
|
|
8
|
+
linkshieldai/types.py,sha256=MyLZBuv77LkNS6TZG0urNnwx--B6oi-kDh8Z5UcDaOI,799
|
|
9
|
+
linkshieldai-0.2.0.dist-info/METADATA,sha256=Ys_I5uKZav3qeFaHV7NWtK1zaNm3aJY518bkWp5FYLk,5471
|
|
10
|
+
linkshieldai-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
linkshieldai-0.2.0.dist-info/entry_points.txt,sha256=eiLlQC042-M3wJASaUsPhM5KQ6ij6Hh0--zqRrMySW4,55
|
|
12
|
+
linkshieldai-0.2.0.dist-info/RECORD,,
|