socially 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .Python
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .idea/
14
+ .vscode/
15
+ .DS_Store
socially-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 socially
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,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: socially
3
+ Version: 0.2.0
4
+ Summary: Authless social media user lookup — X, TikTok, Instagram, GitHub, Reddit, Bluesky, Mastodon, YouTube
5
+ Project-URL: Homepage, https://github.com/lmaohi7181828/socially
6
+ Project-URL: Repository, https://github.com/lmaohi7181828/socially
7
+ Project-URL: Issues, https://github.com/lmaohi7181828/socially/issues
8
+ Author: socially
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: bluesky,github,instagram,mastodon,osint,reddit,social,tiktok,twitter,x,youtube
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Requires-Dist: curl-cffi>=0.7; extra == 'dev'
24
+ Requires-Dist: orjson>=3.10; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Provides-Extra: fast
27
+ Requires-Dist: curl-cffi>=0.7; extra == 'fast'
28
+ Requires-Dist: orjson>=3.10; extra == 'fast'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # socially
32
+
33
+ Authless multi-platform user lookup for Python.
34
+
35
+ **X · TikTok · Instagram · GitHub · Reddit · Bluesky · Mastodon · YouTube**
36
+
37
+ No API keys. No login. Normalized `User` + full `raw` payload.
38
+
39
+ X user lookup follows the guest GraphQL pattern used by [xtekky/x-noauth-api](https://github.com/xtekky/x-noauth-api) (`UserByScreenName`, public bearer + guest token, feature flags).
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install socially
45
+ # or with speed extras:
46
+ pip install "socially[fast]"
47
+ ```
48
+
49
+ From source:
50
+
51
+ ```bash
52
+ pip install -e ".[fast]"
53
+ ```
54
+
55
+ `fast` extra: `orjson` + `curl_cffi` (TLS fingerprint).
56
+
57
+ ## Usage
58
+
59
+ ```python
60
+ from socially import Socially, lookup
61
+
62
+ u = lookup("tiktok", "tiktok")
63
+ print(u.username, u.followers, u.display_name)
64
+
65
+ u = lookup("bluesky", "jay.bsky.team")
66
+ print(u.id, u.followers)
67
+
68
+ s = Socially()
69
+ print(s.platforms())
70
+ results = s.lookup_many("nasa", platforms=["github", "youtube", "bluesky"])
71
+ ```
72
+
73
+ ## Platforms
74
+
75
+ | Name | Authless method |
76
+ |------|-----------------|
77
+ | `x` / `twitter` | Guest token + GraphQL `UserByScreenName` (xtekky-style) |
78
+ | `tiktok` | Profile HTML `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
79
+ | `instagram` / `ig` | `web_profile_info` |
80
+ | `github` / `gh` | Public REST `/users/{user}` |
81
+ | `reddit` | `/user/{user}/about.json` |
82
+ | `bluesky` / `bsky` | `app.bsky.actor.getProfile` |
83
+ | `mastodon` | `/api/v1/accounts/lookup` (`user@instance`) |
84
+ | `youtube` / `yt` | Channel `@handle` `ytInitialData` + og meta |
85
+
86
+ ## User fields
87
+
88
+ `platform`, `id`, `username`, `display_name`, `bio`, `url`, `avatar_url`, `banner_url`,
89
+ `followers`, `following`, `posts`, `likes`, `verified`, `protected`, `location`, `website`, `created_at`, `raw`
90
+
91
+ ## Publish (maintainers)
92
+
93
+ 1. Create a PyPI API token (Account settings → API tokens).
94
+ 2. GitHub repo **Settings → Secrets and variables → Actions** → New secret:
95
+ - Name: `PYPI_API_TOKEN`
96
+ - Value: `pypi-...` (full token; never commit it)
97
+ 3. Optional: GitHub **Environments** → create `pypi` (workflow references it).
98
+ 4. Ship a release by tag:
99
+
100
+ ```bash
101
+ git tag v0.2.0
102
+ git push origin v0.2.0
103
+ ```
104
+
105
+ `.github/workflows/publish.yml` builds the wheel/sdist and runs `twine upload`.
106
+
107
+ Manual: **Actions → Publish to PyPI → Run workflow**.
108
+
109
+ ## Notes
110
+
111
+ Public data only. Some hosts rate-limit datacenter IPs (Instagram, Reddit). Respect each site’s ToS.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,85 @@
1
+ # socially
2
+
3
+ Authless multi-platform user lookup for Python.
4
+
5
+ **X · TikTok · Instagram · GitHub · Reddit · Bluesky · Mastodon · YouTube**
6
+
7
+ No API keys. No login. Normalized `User` + full `raw` payload.
8
+
9
+ X user lookup follows the guest GraphQL pattern used by [xtekky/x-noauth-api](https://github.com/xtekky/x-noauth-api) (`UserByScreenName`, public bearer + guest token, feature flags).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install socially
15
+ # or with speed extras:
16
+ pip install "socially[fast]"
17
+ ```
18
+
19
+ From source:
20
+
21
+ ```bash
22
+ pip install -e ".[fast]"
23
+ ```
24
+
25
+ `fast` extra: `orjson` + `curl_cffi` (TLS fingerprint).
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from socially import Socially, lookup
31
+
32
+ u = lookup("tiktok", "tiktok")
33
+ print(u.username, u.followers, u.display_name)
34
+
35
+ u = lookup("bluesky", "jay.bsky.team")
36
+ print(u.id, u.followers)
37
+
38
+ s = Socially()
39
+ print(s.platforms())
40
+ results = s.lookup_many("nasa", platforms=["github", "youtube", "bluesky"])
41
+ ```
42
+
43
+ ## Platforms
44
+
45
+ | Name | Authless method |
46
+ |------|-----------------|
47
+ | `x` / `twitter` | Guest token + GraphQL `UserByScreenName` (xtekky-style) |
48
+ | `tiktok` | Profile HTML `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
49
+ | `instagram` / `ig` | `web_profile_info` |
50
+ | `github` / `gh` | Public REST `/users/{user}` |
51
+ | `reddit` | `/user/{user}/about.json` |
52
+ | `bluesky` / `bsky` | `app.bsky.actor.getProfile` |
53
+ | `mastodon` | `/api/v1/accounts/lookup` (`user@instance`) |
54
+ | `youtube` / `yt` | Channel `@handle` `ytInitialData` + og meta |
55
+
56
+ ## User fields
57
+
58
+ `platform`, `id`, `username`, `display_name`, `bio`, `url`, `avatar_url`, `banner_url`,
59
+ `followers`, `following`, `posts`, `likes`, `verified`, `protected`, `location`, `website`, `created_at`, `raw`
60
+
61
+ ## Publish (maintainers)
62
+
63
+ 1. Create a PyPI API token (Account settings → API tokens).
64
+ 2. GitHub repo **Settings → Secrets and variables → Actions** → New secret:
65
+ - Name: `PYPI_API_TOKEN`
66
+ - Value: `pypi-...` (full token; never commit it)
67
+ 3. Optional: GitHub **Environments** → create `pypi` (workflow references it).
68
+ 4. Ship a release by tag:
69
+
70
+ ```bash
71
+ git tag v0.2.0
72
+ git push origin v0.2.0
73
+ ```
74
+
75
+ `.github/workflows/publish.yml` builds the wheel/sdist and runs `twine upload`.
76
+
77
+ Manual: **Actions → Publish to PyPI → Run workflow**.
78
+
79
+ ## Notes
80
+
81
+ Public data only. Some hosts rate-limit datacenter IPs (Instagram, Reddit). Respect each site’s ToS.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "socially"
7
+ version = "0.2.0"
8
+ description = "Authless social media user lookup — X, TikTok, Instagram, GitHub, Reddit, Bluesky, Mastodon, YouTube"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "socially" }]
13
+ keywords = [
14
+ "social", "twitter", "x", "tiktok", "instagram", "github",
15
+ "reddit", "bluesky", "mastodon", "youtube", "osint",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ fast = ["orjson>=3.10", "curl_cffi>=0.7"]
32
+ dev = ["pytest>=8.0", "orjson>=3.10", "curl_cffi>=0.7"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/lmaohi7181828/socially"
36
+ Repository = "https://github.com/lmaohi7181828/socially"
37
+ Issues = "https://github.com/lmaohi7181828/socially/issues"
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = [
41
+ "src/socially",
42
+ "tests",
43
+ "README.md",
44
+ "LICENSE",
45
+ "pyproject.toml",
46
+ ]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ pythonpath = ["src"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import Socially
4
+ from .exceptions import (
5
+ PlatformError,
6
+ RateLimited,
7
+ RequestError,
8
+ SociallyError,
9
+ UserNotFound,
10
+ )
11
+ from .models.user import User
12
+ from .platforms import PLATFORMS
13
+
14
+ __all__ = [
15
+ "PLATFORMS",
16
+ "PlatformError",
17
+ "RateLimited",
18
+ "RequestError",
19
+ "Socially",
20
+ "SociallyError",
21
+ "User",
22
+ "UserNotFound",
23
+ "lookup",
24
+ ]
25
+
26
+ __version__ = "0.2.0"
27
+
28
+
29
+ def lookup(platform: str, username: str) -> User:
30
+ return Socially().lookup(platform, username)
@@ -0,0 +1,15 @@
1
+ from .client import Socially as Socially
2
+ from .exceptions import (
3
+ PlatformError as PlatformError,
4
+ RateLimited as RateLimited,
5
+ RequestError as RequestError,
6
+ SociallyError as SociallyError,
7
+ UserNotFound as UserNotFound,
8
+ )
9
+ from .models.user import User as User
10
+ from .platforms import PLATFORMS as PLATFORMS
11
+
12
+ __version__: str
13
+ __all__: list[str]
14
+
15
+ def lookup(platform: str, username: str) -> User: ...
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+ from .exceptions import SociallyError
6
+ from .models.user import User
7
+ from .platforms import PLATFORMS, Platform
8
+
9
+ _ALIASES = {
10
+ "twitter": "x",
11
+ "ig": "instagram",
12
+ "gh": "github",
13
+ "bsky": "bluesky",
14
+ "yt": "youtube",
15
+ }
16
+
17
+
18
+ class Socially:
19
+ __slots__ = ("_providers",)
20
+
21
+ def __init__(self, platforms: Iterable[str] | None = None) -> None:
22
+ self._providers: dict[str, Platform] = {}
23
+ names = platforms if platforms is not None else PLATFORMS.keys()
24
+ seen: set[type[Platform]] = set()
25
+ for name in names:
26
+ key = name.lower().strip()
27
+ cls = PLATFORMS.get(key)
28
+ if cls is None or cls in seen:
29
+ continue
30
+ seen.add(cls)
31
+ self._providers[cls.name] = cls()
32
+
33
+ def platforms(self) -> list[str]:
34
+ return sorted(self._providers)
35
+
36
+ def lookup(self, platform: str, username: str) -> User:
37
+ key = _ALIASES.get(platform.lower().strip(), platform.lower().strip())
38
+ provider = self._providers.get(key)
39
+ if provider is None:
40
+ cls = PLATFORMS.get(key)
41
+ if cls is None:
42
+ raise SociallyError(f"unknown platform: {platform}")
43
+ provider = cls()
44
+ self._providers[cls.name] = provider
45
+ key = cls.name
46
+ return provider.lookup(username)
47
+
48
+ def lookup_many(
49
+ self,
50
+ username: str,
51
+ platforms: Iterable[str] | None = None,
52
+ ) -> dict[str, User | BaseException]:
53
+ targets = list(platforms) if platforms is not None else self.platforms()
54
+ out: dict[str, User | BaseException] = {}
55
+ for p in targets:
56
+ try:
57
+ out[p] = self.lookup(p, username)
58
+ except BaseException as e:
59
+ out[p] = e
60
+ return out
@@ -0,0 +1,15 @@
1
+ from collections.abc import Iterable
2
+ from typing import BaseException
3
+
4
+ from .models.user import User
5
+ from .platforms.base import Platform
6
+
7
+ class Socially:
8
+ def __init__(self, platforms: Iterable[str] | None = ...) -> None: ...
9
+ def platforms(self) -> list[str]: ...
10
+ def lookup(self, platform: str, username: str) -> User: ...
11
+ def lookup_many(
12
+ self,
13
+ username: str,
14
+ platforms: Iterable[str] | None = ...,
15
+ ) -> dict[str, User | BaseException]: ...
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SociallyError(Exception):
5
+ pass
6
+
7
+
8
+ class PlatformError(SociallyError):
9
+ def __init__(self, platform: str, message: str) -> None:
10
+ self.platform = platform
11
+ super().__init__(f"[{platform}] {message}")
12
+
13
+
14
+ class UserNotFound(PlatformError):
15
+ pass
16
+
17
+
18
+ class RateLimited(PlatformError):
19
+ pass
20
+
21
+
22
+ class RequestError(PlatformError):
23
+ pass
@@ -0,0 +1,9 @@
1
+ class SociallyError(Exception): ...
2
+
3
+ class PlatformError(SociallyError):
4
+ platform: str
5
+ def __init__(self, platform: str, message: str) -> None: ...
6
+
7
+ class UserNotFound(PlatformError): ...
8
+ class RateLimited(PlatformError): ...
9
+ class RequestError(PlatformError): ...
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ try:
6
+ import orjson as _json
7
+
8
+ def loads(data: bytes | str) -> Any:
9
+ if isinstance(data, str):
10
+ data = data.encode()
11
+ return _json.loads(data)
12
+
13
+ def dumps(obj: Any) -> str:
14
+ return _json.dumps(obj).decode()
15
+
16
+ except ImportError:
17
+ import json as _json
18
+
19
+ def loads(data: bytes | str) -> Any:
20
+ if isinstance(data, bytes):
21
+ data = data.decode()
22
+ return _json.loads(data)
23
+
24
+ def dumps(obj: Any) -> str:
25
+ return _json.dumps(obj, separators=(",", ":"))
26
+
27
+ UA = (
28
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
29
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
30
+ "Chrome/131.0.0.0 Safari/537.36"
31
+ )
32
+
33
+
34
+ def session(impersonate: str | None = "chrome"):
35
+ try:
36
+ from curl_cffi import requests as creq
37
+
38
+ return creq.Session(impersonate=impersonate or "chrome")
39
+ except ImportError:
40
+ import requests
41
+
42
+ s = requests.Session()
43
+ s.headers["User-Agent"] = UA
44
+ return s
45
+
46
+
47
+ def get(
48
+ url: str,
49
+ *,
50
+ headers: dict[str, str] | None = None,
51
+ timeout: float = 15.0,
52
+ impersonate: str | None = "chrome",
53
+ ) -> Any:
54
+ s = session(impersonate)
55
+ h = {"User-Agent": UA, "Accept": "*/*"}
56
+ if headers:
57
+ h.update(headers)
58
+ return s.get(url, headers=h, timeout=timeout)
59
+
60
+
61
+ def post(
62
+ url: str,
63
+ *,
64
+ headers: dict[str, str] | None = None,
65
+ data: Any = None,
66
+ json_body: Any = None,
67
+ timeout: float = 15.0,
68
+ impersonate: str | None = "chrome",
69
+ ) -> Any:
70
+ s = session(impersonate)
71
+ h = {"User-Agent": UA, "Accept": "*/*"}
72
+ if headers:
73
+ h.update(headers)
74
+ kwargs: dict[str, Any] = {"headers": h, "timeout": timeout}
75
+ if json_body is not None:
76
+ kwargs["data"] = dumps(json_body).encode() if not isinstance(json_body, (str, bytes)) else json_body
77
+ h.setdefault("Content-Type", "application/json")
78
+ elif data is not None:
79
+ kwargs["data"] = data
80
+ return s.post(url, **kwargs)
@@ -0,0 +1,3 @@
1
+ from .user import User
2
+
3
+ __all__ = ["User"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class User:
9
+ platform: str
10
+ id: str | None = None
11
+ username: str | None = None
12
+ display_name: str | None = None
13
+ bio: str | None = None
14
+ url: str | None = None
15
+ avatar_url: str | None = None
16
+ banner_url: str | None = None
17
+ followers: int | None = None
18
+ following: int | None = None
19
+ posts: int | None = None
20
+ likes: int | None = None
21
+ verified: bool | None = None
22
+ protected: bool | None = None
23
+ location: str | None = None
24
+ website: str | None = None
25
+ created_at: str | None = None
26
+ raw: dict[str, Any] = field(default_factory=dict)
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return asdict(self)
@@ -0,0 +1,44 @@
1
+ from typing import Any
2
+
3
+ class User:
4
+ platform: str
5
+ id: str | None
6
+ username: str | None
7
+ display_name: str | None
8
+ bio: str | None
9
+ url: str | None
10
+ avatar_url: str | None
11
+ banner_url: str | None
12
+ followers: int | None
13
+ following: int | None
14
+ posts: int | None
15
+ likes: int | None
16
+ verified: bool | None
17
+ protected: bool | None
18
+ location: str | None
19
+ website: str | None
20
+ created_at: str | None
21
+ raw: dict[str, Any]
22
+
23
+ def __init__(
24
+ self,
25
+ platform: str,
26
+ id: str | None = ...,
27
+ username: str | None = ...,
28
+ display_name: str | None = ...,
29
+ bio: str | None = ...,
30
+ url: str | None = ...,
31
+ avatar_url: str | None = ...,
32
+ banner_url: str | None = ...,
33
+ followers: int | None = ...,
34
+ following: int | None = ...,
35
+ posts: int | None = ...,
36
+ likes: int | None = ...,
37
+ verified: bool | None = ...,
38
+ protected: bool | None = ...,
39
+ location: str | None = ...,
40
+ website: str | None = ...,
41
+ created_at: str | None = ...,
42
+ raw: dict[str, Any] = ...,
43
+ ) -> None: ...
44
+ def to_dict(self) -> dict[str, Any]: ...
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Platform
4
+ from .bluesky import Bluesky
5
+ from .github import GitHub
6
+ from .instagram import Instagram
7
+ from .mastodon import Mastodon
8
+ from .reddit import Reddit
9
+ from .tiktok import TikTok
10
+ from .x import X
11
+ from .youtube import YouTube
12
+
13
+ PLATFORMS: dict[str, type[Platform]] = {
14
+ "x": X,
15
+ "twitter": X,
16
+ "tiktok": TikTok,
17
+ "instagram": Instagram,
18
+ "ig": Instagram,
19
+ "github": GitHub,
20
+ "gh": GitHub,
21
+ "reddit": Reddit,
22
+ "bluesky": Bluesky,
23
+ "bsky": Bluesky,
24
+ "mastodon": Mastodon,
25
+ "youtube": YouTube,
26
+ "yt": YouTube,
27
+ }
28
+
29
+ __all__ = [
30
+ "PLATFORMS",
31
+ "Platform",
32
+ "X",
33
+ "TikTok",
34
+ "Instagram",
35
+ "GitHub",
36
+ "Reddit",
37
+ "Bluesky",
38
+ "Mastodon",
39
+ "YouTube",
40
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ..models.user import User
6
+
7
+
8
+ class Platform(ABC):
9
+ name: str
10
+
11
+ @abstractmethod
12
+ def lookup(self, username: str) -> User:
13
+ ...
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from ..models.user import User
4
+
5
+ class Platform(ABC):
6
+ name: str
7
+ @abstractmethod
8
+ def lookup(self, username: str) -> User: ...