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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ linkshieldai = linkshieldai.cli:main