socially 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.
socially/__init__.py ADDED
@@ -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)
socially/__init__.pyi ADDED
@@ -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: ...
socially/client.py ADDED
@@ -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
socially/client.pyi ADDED
@@ -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]: ...
socially/exceptions.py ADDED
@@ -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): ...
socially/http.py ADDED
@@ -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: ...
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import RequestError, UserNotFound
4
+ from ..http import get, loads
5
+ from ..models.user import User
6
+ from .base import Platform
7
+
8
+
9
+ class Bluesky(Platform):
10
+ name = "bluesky"
11
+
12
+ def lookup(self, username: str) -> User:
13
+ handle = username.lstrip("@").strip()
14
+ if "." not in handle:
15
+ handle = f"{handle}.bsky.social"
16
+ r = get(
17
+ f"https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={handle}",
18
+ headers={"Accept": "application/json"},
19
+ impersonate=None,
20
+ )
21
+ if r.status_code == 400 or r.status_code == 404:
22
+ raise UserNotFound(self.name, f"user '{username}' not found")
23
+ if r.status_code != 200:
24
+ raise RequestError(self.name, f"HTTP {r.status_code}")
25
+ data = loads(r.content)
26
+ return User(
27
+ platform=self.name,
28
+ id=data.get("did"),
29
+ username=data.get("handle"),
30
+ display_name=data.get("displayName"),
31
+ bio=data.get("description"),
32
+ url=f"https://bsky.app/profile/{data.get('handle')}",
33
+ avatar_url=data.get("avatar"),
34
+ banner_url=data.get("banner"),
35
+ followers=data.get("followersCount"),
36
+ following=data.get("followsCount"),
37
+ posts=data.get("postsCount"),
38
+ created_at=data.get("createdAt"),
39
+ raw=data,
40
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import RequestError, UserNotFound
4
+ from ..http import get, loads
5
+ from ..models.user import User
6
+ from .base import Platform
7
+
8
+
9
+ class GitHub(Platform):
10
+ name = "github"
11
+
12
+ def lookup(self, username: str) -> User:
13
+ username = username.lstrip("@").strip()
14
+ r = get(
15
+ f"https://api.github.com/users/{username}",
16
+ headers={
17
+ "Accept": "application/vnd.github+json",
18
+ "User-Agent": "socially/0.2",
19
+ },
20
+ impersonate=None,
21
+ )
22
+ if r.status_code == 404:
23
+ raise UserNotFound(self.name, f"user '{username}' not found")
24
+ if r.status_code == 403:
25
+ raise RequestError(self.name, "rate limited")
26
+ if r.status_code != 200:
27
+ raise RequestError(self.name, f"HTTP {r.status_code}")
28
+ data = loads(r.content)
29
+ return User(
30
+ platform=self.name,
31
+ id=str(data["id"]) if data.get("id") is not None else None,
32
+ username=data.get("login"),
33
+ display_name=data.get("name"),
34
+ bio=data.get("bio"),
35
+ url=data.get("html_url"),
36
+ avatar_url=data.get("avatar_url"),
37
+ followers=data.get("followers"),
38
+ following=data.get("following"),
39
+ posts=data.get("public_repos"),
40
+ location=data.get("location"),
41
+ website=data.get("blog") or None,
42
+ created_at=data.get("created_at"),
43
+ raw=data,
44
+ )
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..exceptions import RequestError, UserNotFound
6
+ from ..http import get, loads
7
+ from ..models.user import User
8
+ from .base import Platform
9
+
10
+
11
+ class Instagram(Platform):
12
+ name = "instagram"
13
+
14
+ def lookup(self, username: str) -> User:
15
+ username = username.lstrip("@").strip()
16
+ r = get(
17
+ f"https://i.instagram.com/api/v1/users/web_profile_info/?username={username}",
18
+ headers={
19
+ "X-IG-App-ID": "936619743392459",
20
+ "Accept": "*/*",
21
+ },
22
+ )
23
+ if r.status_code == 404:
24
+ raise UserNotFound(self.name, f"user '{username}' not found")
25
+ if r.status_code in (401, 403, 429):
26
+ raise RequestError(self.name, f"HTTP {r.status_code}")
27
+ if r.status_code != 200:
28
+ raise RequestError(self.name, f"HTTP {r.status_code}")
29
+ try:
30
+ payload = loads(r.content)
31
+ except Exception as e:
32
+ raise RequestError(self.name, f"invalid JSON: {e}") from e
33
+ user = (payload.get("data") or {}).get("user")
34
+ if not user:
35
+ raise UserNotFound(self.name, f"user '{username}' not found")
36
+ return self._map(user)
37
+
38
+ def _map(self, user: dict[str, Any]) -> User:
39
+ followers = (user.get("edge_followed_by") or {}).get("count")
40
+ if followers is None:
41
+ followers = user.get("follower_count")
42
+ following = (user.get("edge_follow") or {}).get("count")
43
+ if following is None:
44
+ following = user.get("following_count")
45
+ posts = (user.get("edge_owner_to_timeline_media") or {}).get("count")
46
+ if posts is None:
47
+ posts = user.get("media_count")
48
+ return User(
49
+ platform=self.name,
50
+ id=str(user["id"]) if user.get("id") is not None else None,
51
+ username=user.get("username"),
52
+ display_name=user.get("full_name"),
53
+ bio=user.get("biography"),
54
+ url=f"https://www.instagram.com/{user.get('username')}/",
55
+ avatar_url=user.get("profile_pic_url_hd") or user.get("profile_pic_url"),
56
+ followers=followers,
57
+ following=following,
58
+ posts=posts,
59
+ verified=user.get("is_verified"),
60
+ protected=user.get("is_private"),
61
+ website=user.get("external_url"),
62
+ raw=user,
63
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ from ..exceptions import RequestError, UserNotFound
6
+ from ..http import get, loads
7
+ from ..models.user import User
8
+ from .base import Platform
9
+
10
+
11
+ class Mastodon(Platform):
12
+ name = "mastodon"
13
+
14
+ def lookup(self, username: str) -> User:
15
+ raw = username.lstrip("@").strip()
16
+ if "@" in raw:
17
+ local, instance = raw.rsplit("@", 1)
18
+ else:
19
+ local, instance = raw, "mastodon.social"
20
+ r = get(
21
+ f"https://{instance}/api/v1/accounts/lookup?acct={local}",
22
+ headers={"Accept": "application/json"},
23
+ impersonate=None,
24
+ )
25
+ if r.status_code == 404:
26
+ raise UserNotFound(self.name, f"user '{username}' not found")
27
+ if r.status_code != 200:
28
+ acct = quote(f"{local}@{instance}")
29
+ r = get(
30
+ f"https://{instance}/api/v1/accounts/lookup?acct={acct}",
31
+ headers={"Accept": "application/json"},
32
+ impersonate=None,
33
+ )
34
+ if r.status_code == 404:
35
+ raise UserNotFound(self.name, f"user '{username}' not found")
36
+ if r.status_code != 200:
37
+ raise RequestError(self.name, f"HTTP {r.status_code}")
38
+ data = loads(r.content)
39
+ return User(
40
+ platform=self.name,
41
+ id=str(data["id"]) if data.get("id") is not None else None,
42
+ username=data.get("acct") or data.get("username"),
43
+ display_name=data.get("display_name"),
44
+ bio=data.get("note"),
45
+ url=data.get("url"),
46
+ avatar_url=data.get("avatar"),
47
+ banner_url=data.get("header"),
48
+ followers=data.get("followers_count"),
49
+ following=data.get("following_count"),
50
+ posts=data.get("statuses_count"),
51
+ created_at=data.get("created_at"),
52
+ raw=data,
53
+ )
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from ..exceptions import RequestError, UserNotFound
4
+ from ..http import get, loads
5
+ from ..models.user import User
6
+ from .base import Platform
7
+
8
+ _HEADERS = {
9
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
10
+ "Accept": "application/json",
11
+ }
12
+
13
+
14
+ class Reddit(Platform):
15
+ name = "reddit"
16
+
17
+ def lookup(self, username: str) -> User:
18
+ username = username.lstrip("u/").lstrip("/").strip()
19
+ for base in (
20
+ f"https://www.reddit.com/user/{username}/about.json",
21
+ f"https://old.reddit.com/user/{username}/about.json",
22
+ f"https://api.reddit.com/user/{username}/about",
23
+ ):
24
+ r = get(base, headers=_HEADERS, impersonate="chrome")
25
+ if r.status_code == 404:
26
+ raise UserNotFound(self.name, f"user '{username}' not found")
27
+ if r.status_code == 200:
28
+ return self._map(loads(r.content), username)
29
+ if r.status_code not in (403, 429):
30
+ raise RequestError(self.name, f"HTTP {r.status_code}")
31
+ raise RequestError(self.name, "blocked or rate limited")
32
+
33
+ def _map(self, data: dict, username: str) -> User:
34
+ d = data.get("data") or data
35
+ if d.get("is_suspended") or not d.get("name"):
36
+ raise UserNotFound(self.name, f"user '{username}' not found or suspended")
37
+ sub = d.get("subreddit") or {}
38
+ return User(
39
+ platform=self.name,
40
+ id=d.get("id") or d.get("name"),
41
+ username=d.get("name"),
42
+ display_name=sub.get("title") or d.get("name"),
43
+ bio=sub.get("public_description"),
44
+ url=f"https://www.reddit.com/user/{d.get('name')}",
45
+ avatar_url=d.get("icon_img") or d.get("snoovatar_img"),
46
+ banner_url=sub.get("banner_img"),
47
+ followers=d.get("total_karma"),
48
+ posts=d.get("link_karma"),
49
+ likes=d.get("comment_karma"),
50
+ created_at=str(d["created_utc"]) if d.get("created_utc") is not None else None,
51
+ raw=data,
52
+ )
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from ..exceptions import RequestError, UserNotFound
7
+ from ..http import get, loads
8
+ from ..models.user import User
9
+ from .base import Platform
10
+
11
+ _RE_UNIVERSAL = re.compile(
12
+ r'<script[^>]*id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)</script>',
13
+ re.DOTALL,
14
+ )
15
+ _RE_SIGI = re.compile(r'<script[^>]*id="SIGI_STATE"[^>]*>(.*?)</script>', re.DOTALL)
16
+
17
+
18
+ def _int(v: Any) -> int | None:
19
+ if v is None:
20
+ return None
21
+ try:
22
+ return int(v)
23
+ except (TypeError, ValueError):
24
+ return None
25
+
26
+
27
+ def _extract(html: str) -> dict[str, Any] | None:
28
+ m = _RE_UNIVERSAL.search(html)
29
+ if m:
30
+ try:
31
+ data = loads(m.group(1))
32
+ info = (data.get("__DEFAULT_SCOPE__") or {}).get("webapp.user-detail", {}).get("userInfo")
33
+ if info:
34
+ return info
35
+ except Exception:
36
+ pass
37
+ m = _RE_SIGI.search(html)
38
+ if m:
39
+ try:
40
+ data = loads(m.group(1))
41
+ users = (data.get("UserModule") or {}).get("users") or {}
42
+ if users:
43
+ first = next(iter(users.values()))
44
+ stats = ((data.get("UserModule") or {}).get("stats") or {}).get(first.get("id") or "", {})
45
+ return {"user": first, "stats": stats}
46
+ except Exception:
47
+ pass
48
+ return None
49
+
50
+
51
+ class TikTok(Platform):
52
+ name = "tiktok"
53
+
54
+ def lookup(self, username: str) -> User:
55
+ username = username.lstrip("@").strip()
56
+ r = get(
57
+ f"https://www.tiktok.com/@{username}",
58
+ headers={"Accept": "text/html", "Accept-Language": "en-US,en;q=0.9"},
59
+ )
60
+ if r.status_code == 404:
61
+ raise UserNotFound(self.name, f"user '{username}' not found")
62
+ if r.status_code != 200:
63
+ raise RequestError(self.name, f"HTTP {r.status_code}")
64
+ info = _extract(r.text)
65
+ if not info:
66
+ raise UserNotFound(self.name, f"could not parse '{username}'")
67
+ user = info.get("user") or {}
68
+ stats = info.get("stats") or info.get("statsV2") or {}
69
+ uid = user.get("uniqueId") or username
70
+ return User(
71
+ platform=self.name,
72
+ id=str(user["id"]) if user.get("id") is not None else None,
73
+ username=uid,
74
+ display_name=user.get("nickname"),
75
+ bio=user.get("signature"),
76
+ url=f"https://www.tiktok.com/@{uid}",
77
+ avatar_url=user.get("avatarLarger") or user.get("avatarMedium"),
78
+ followers=_int(stats.get("followerCount")),
79
+ following=_int(stats.get("followingCount")),
80
+ posts=_int(stats.get("videoCount")),
81
+ likes=_int(stats.get("heartCount") or stats.get("heart")),
82
+ verified=bool(user["verified"]) if "verified" in user else None,
83
+ protected=bool(user["privateAccount"]) if "privateAccount" in user else None,
84
+ raw=info,
85
+ )
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from ..exceptions import RequestError, UserNotFound
7
+ from ..http import dumps, get, loads, session
8
+ from ..models.user import User
9
+ from .base import Platform
10
+
11
+ BEARER = (
12
+ "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
13
+ "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
14
+ )
15
+
16
+ PROFILE_FEATURES: dict[str, bool] = {
17
+ "creator_subscriptions_tweet_preview_api_enabled": True,
18
+ "hidden_profile_subscriptions_enabled": True,
19
+ "highlights_tweets_tab_ui_enabled": True,
20
+ "profile_label_improvements_pcf_label_in_post_enabled": True,
21
+ "responsive_web_graphql_timeline_navigation_enabled": True,
22
+ "responsive_web_profile_redirect_enabled": True,
23
+ "responsive_web_twitter_article_notes_tab_enabled": True,
24
+ "rweb_tipjar_consumption_enabled": False,
25
+ "subscriptions_feature_can_gift_premium": True,
26
+ "subscriptions_verification_info_is_identity_verified_enabled": True,
27
+ "subscriptions_verification_info_verified_since_enabled": True,
28
+ "verified_phone_label_enabled": False,
29
+ }
30
+
31
+ PROFILE_TOGGLES: dict[str, bool] = {
32
+ "withAuxiliaryUserLabels": True,
33
+ "withPayments": False,
34
+ }
35
+
36
+ OPS = (
37
+ ("Gb-d6r0vxPOADdG62OEBpQ", "UserByScreenName", True),
38
+ ("L_eCyvUxu-QprwFaWK0Wyg", "UserByScreenName", False),
39
+ )
40
+
41
+
42
+ class X(Platform):
43
+ name = "x"
44
+
45
+ __slots__ = ("_guest",)
46
+
47
+ def __init__(self) -> None:
48
+ self._guest: str | None = None
49
+
50
+ def _mint_guest(self) -> str:
51
+ s = session("chrome")
52
+ r = s.post(
53
+ "https://api.x.com/1.1/guest/activate.json",
54
+ headers={"Authorization": f"Bearer {BEARER}"},
55
+ timeout=15,
56
+ )
57
+ if r.status_code != 200:
58
+ raise RequestError(self.name, f"guest activate HTTP {r.status_code}")
59
+ token = loads(r.content).get("guest_token")
60
+ if not token:
61
+ raise RequestError(self.name, "missing guest_token")
62
+ self._guest = str(token)
63
+ return self._guest
64
+
65
+ def _guest_token(self) -> str:
66
+ return self._guest or self._mint_guest()
67
+
68
+ def lookup(self, username: str) -> User:
69
+ username = username.lstrip("@").strip()
70
+ guest = self._guest_token()
71
+ last_err: Exception | None = None
72
+ for query_id, op_name, full in OPS:
73
+ try:
74
+ return self._graphql_user(username, guest, query_id, op_name, full)
75
+ except UserNotFound:
76
+ raise
77
+ except Exception as e:
78
+ last_err = e
79
+ self._guest = None
80
+ guest = self._guest_token()
81
+ try:
82
+ return self._html_user(username)
83
+ except Exception:
84
+ if last_err:
85
+ raise last_err
86
+ raise
87
+
88
+ def _graphql_user(
89
+ self,
90
+ username: str,
91
+ guest: str,
92
+ query_id: str,
93
+ op_name: str,
94
+ full: bool,
95
+ ) -> User:
96
+ variables: dict[str, Any] = (
97
+ {"screen_name": username, "withGrokTranslatedBio": False}
98
+ if full
99
+ else {"screen_name": username}
100
+ )
101
+ params = f"variables={quote(dumps(variables))}"
102
+ if full:
103
+ params += f"&features={quote(dumps(PROFILE_FEATURES))}"
104
+ params += f"&fieldToggles={quote(dumps(PROFILE_TOGGLES))}"
105
+ url = f"https://api.x.com/graphql/{query_id}/{op_name}?{params}"
106
+ s = session("chrome")
107
+ r = s.get(
108
+ url,
109
+ headers={
110
+ "Authorization": f"Bearer {BEARER}",
111
+ "x-guest-token": guest,
112
+ "Content-Type": "application/json",
113
+ },
114
+ timeout=15,
115
+ )
116
+ if r.status_code == 404:
117
+ raise UserNotFound(self.name, f"user '{username}' not found")
118
+ if r.status_code == 429:
119
+ raise RequestError(self.name, "rate limited")
120
+ if r.status_code != 200:
121
+ raise RequestError(self.name, f"GraphQL HTTP {r.status_code}")
122
+ data = loads(r.content)
123
+ result = ((data.get("data") or {}).get("user") or {}).get("result") or {}
124
+ if not result or result.get("__typename") == "UserUnavailable":
125
+ raise UserNotFound(self.name, f"user '{username}' not found")
126
+ return self._from_result(result, username)
127
+
128
+ def _from_result(self, result: dict[str, Any], username: str) -> User:
129
+ legacy = result.get("legacy") or {}
130
+ core = result.get("core") or {}
131
+ website = None
132
+ try:
133
+ urls = ((legacy.get("entities") or {}).get("url") or {}).get("urls") or []
134
+ if urls:
135
+ website = urls[0].get("expanded_url")
136
+ except Exception:
137
+ pass
138
+ screen = legacy.get("screen_name") or core.get("screen_name") or username
139
+ avatar = legacy.get("profile_image_url_https") or ""
140
+ if avatar:
141
+ avatar = avatar.replace("_normal", "")
142
+ return User(
143
+ platform=self.name,
144
+ id=str(result.get("rest_id") or legacy.get("id_str") or ""),
145
+ username=screen,
146
+ display_name=legacy.get("name") or core.get("name"),
147
+ bio=legacy.get("description"),
148
+ url=f"https://x.com/{screen}",
149
+ avatar_url=avatar or None,
150
+ banner_url=legacy.get("profile_banner_url"),
151
+ followers=legacy.get("followers_count"),
152
+ following=legacy.get("friends_count"),
153
+ posts=legacy.get("statuses_count"),
154
+ likes=legacy.get("favourites_count"),
155
+ verified=bool(result.get("is_blue_verified") or legacy.get("verified")),
156
+ protected=legacy.get("protected"),
157
+ location=legacy.get("location"),
158
+ website=website,
159
+ created_at=legacy.get("created_at"),
160
+ raw=result,
161
+ )
162
+
163
+ def _html_user(self, username: str) -> User:
164
+ import re
165
+
166
+ r = get(f"https://x.com/{username}")
167
+ if r.status_code == 404:
168
+ raise UserNotFound(self.name, f"user '{username}' not found")
169
+ if r.status_code != 200:
170
+ raise RequestError(self.name, f"HTML HTTP {r.status_code}")
171
+ text = r.text
172
+ title_m = re.search(r'property="og:title"\s+content="([^"]+)"', text)
173
+ desc_m = re.search(r'property="og:description"\s+content="([^"]*)"', text)
174
+ img_m = re.search(r'property="og:image"\s+content="([^"]+)"', text)
175
+ if not title_m:
176
+ raise UserNotFound(self.name, f"could not resolve '{username}'")
177
+ title = title_m.group(1)
178
+ display = title.split("(")[0].strip() if "(" in title else title
179
+ return User(
180
+ platform=self.name,
181
+ username=username,
182
+ display_name=display,
183
+ bio=desc_m.group(1) if desc_m else None,
184
+ url=f"https://x.com/{username}",
185
+ avatar_url=img_m.group(1) if img_m else None,
186
+ raw={"source": "html_meta", "title": title},
187
+ )
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from ..exceptions import RequestError, UserNotFound
7
+ from ..http import get, loads
8
+ from ..models.user import User
9
+ from .base import Platform
10
+
11
+ _RE_INIT = re.compile(r"ytInitialData\s*=\s*(\{.+?\})\s*;", re.DOTALL)
12
+ _RE_OG_TITLE = re.compile(r'<meta\s+property="og:title"\s+content="([^"]+)"')
13
+ _RE_OG_DESC = re.compile(r'<meta\s+property="og:description"\s+content="([^"]*)"')
14
+ _RE_OG_IMAGE = re.compile(r'<meta\s+property="og:image"\s+content="([^"]+)"')
15
+ _RE_CHANNEL_ID = re.compile(r'"channelId"\s*:\s*"(UC[\w-]+)"')
16
+ _RE_META_TITLE = re.compile(r'"channelMetadataRenderer"\s*:\s*\{[^}]*?"title"\s*:\s*"([^"]+)"')
17
+ _RE_SUBS = re.compile(r'"subscriberCountText"\s*:\s*\{[^}]*?"simpleText"\s*:\s*"([^"]+)"')
18
+
19
+
20
+ class YouTube(Platform):
21
+ name = "youtube"
22
+
23
+ def lookup(self, username: str) -> User:
24
+ handle = username.lstrip("@").strip()
25
+ r = get(
26
+ f"https://www.youtube.com/@{handle}",
27
+ headers={"Accept-Language": "en-US,en;q=0.9"},
28
+ )
29
+ if r.status_code == 404:
30
+ raise UserNotFound(self.name, f"channel '{username}' not found")
31
+ if r.status_code != 200:
32
+ raise RequestError(self.name, f"HTTP {r.status_code}")
33
+ text = r.text
34
+ meta = self._parse(text, handle)
35
+ if not meta.get("channelId") and not meta.get("title"):
36
+ raise UserNotFound(self.name, f"channel '{username}' not found")
37
+ return User(
38
+ platform=self.name,
39
+ id=meta.get("channelId"),
40
+ username=meta.get("vanity") or handle,
41
+ display_name=meta.get("title"),
42
+ bio=meta.get("description"),
43
+ url=f"https://www.youtube.com/@{handle}",
44
+ avatar_url=meta.get("avatar"),
45
+ followers=meta.get("subscribers"),
46
+ raw=meta,
47
+ )
48
+
49
+ def _parse(self, text: str, handle: str) -> dict[str, Any]:
50
+ out: dict[str, Any] = {"vanity": handle}
51
+ m = _RE_OG_TITLE.search(text)
52
+ if m:
53
+ out["title"] = m.group(1)
54
+ m = _RE_META_TITLE.search(text)
55
+ if m:
56
+ out["title"] = m.group(1)
57
+ m = _RE_OG_DESC.search(text)
58
+ if m:
59
+ out["description"] = m.group(1)
60
+ m = _RE_OG_IMAGE.search(text)
61
+ if m:
62
+ out["avatar"] = m.group(1)
63
+ m = _RE_CHANNEL_ID.search(text)
64
+ if m:
65
+ out["channelId"] = m.group(1)
66
+ m = _RE_SUBS.search(text)
67
+ if m:
68
+ out["subscribers"] = _parse_count(m.group(1))
69
+ try:
70
+ jm = _RE_INIT.search(text)
71
+ if jm:
72
+ data = loads(jm.group(1))
73
+ header = (data.get("header") or {}).get("c4TabbedHeaderRenderer") or {}
74
+ if header.get("title"):
75
+ out["title"] = header["title"]
76
+ if header.get("channelId"):
77
+ out["channelId"] = header["channelId"]
78
+ thumbs = (header.get("avatar") or {}).get("thumbnails") or []
79
+ if thumbs:
80
+ out["avatar"] = thumbs[-1].get("url")
81
+ subs = (header.get("subscriberCountText") or {}).get("simpleText") or ""
82
+ if subs:
83
+ out["subscribers"] = _parse_count(subs)
84
+ except Exception:
85
+ pass
86
+ return out
87
+
88
+
89
+ def _parse_count(text: str) -> int | None:
90
+ if not text:
91
+ return None
92
+ t = text.lower().replace("subscribers", "").replace(",", "").strip()
93
+ mult = 1.0
94
+ if t.endswith("k"):
95
+ mult, t = 1_000.0, t[:-1]
96
+ elif t.endswith("m"):
97
+ mult, t = 1_000_000.0, t[:-1]
98
+ elif t.endswith("b"):
99
+ mult, t = 1_000_000_000.0, t[:-1]
100
+ try:
101
+ return int(float(t) * mult)
102
+ except ValueError:
103
+ return None
socially/py.typed ADDED
File without changes
@@ -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,26 @@
1
+ socially/__init__.py,sha256=P7IUyHF4OQYTvw_VbbXkCf1VhLykCaEPzKQH5ntIjQw,546
2
+ socially/__init__.pyi,sha256=UOI37SzgoXRh9cvo1rk60rnU-IvpThzi2v-BAeef3lA,417
3
+ socially/client.py,sha256=5Fmr8G1koZQTmgvb_TKvaR4KtsRWk9KtzXaHXYKyi88,1859
4
+ socially/client.pyi,sha256=awKeQTiT7IX9lVGmB5aaw26q9Bli-Y0bgcxC2UpXkBw,487
5
+ socially/exceptions.py,sha256=qtJp_feIB1miGyl7aGXokgr1z666VhGMi9gebwSFIrw,399
6
+ socially/exceptions.pyi,sha256=pACdPs_L7RWY0OXfksdQlHjMheTYtFfVTZ4K3kKGDYE,273
7
+ socially/http.py,sha256=uJUvdgDuXt-fJhFPPHgPpOHFDQrcIFhCQZ8yppji2w8,2019
8
+ socially/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ socially/models/__init__.py,sha256=l9ueTdsKVif6Ufh5a4ikcgFWncsnPbMtfbjgg5G660o,43
10
+ socially/models/user.py,sha256=lwX7fiewOrUfzPcjMI987L6soBEMH9zHtA7_TfZi3XA,791
11
+ socially/models/user.pyi,sha256=8mO82K38C0AbhSORJUWg-KhfLr9vkZ3fxwMbQ_ZHdNk,1193
12
+ socially/platforms/__init__.py,sha256=zhcu7pXsLO7d5zArHtq4tFU1TlqCPh_6Zn6imkLhSgA,766
13
+ socially/platforms/base.py,sha256=a7zRrN48VcKpsNtQomBfidgE30wL7bgLUNTpJA1Ae4I,219
14
+ socially/platforms/base.pyi,sha256=-avYNbiCy4g1TkOPl6hKgGmZhjRf5xBN3Ye-rROsIb4,173
15
+ socially/platforms/bluesky.py,sha256=kK0N8A5wr7jkZfETCYuYMzJGwM1p_TbByh4hznE3_vs,1440
16
+ socially/platforms/github.py,sha256=vWabiLHGLUsjx3Qn1TubsYRbDT6VA2GhzxT4VDmYfuc,1527
17
+ socially/platforms/instagram.py,sha256=uRQE9okl6lQn4wTanzlYp7-uw6zwaK-y_QUxsSUZX2o,2407
18
+ socially/platforms/mastodon.py,sha256=8Mc8eBPyz0dUCM2kQnaHePVG099ghfWQ7Ujucpicpfs,1911
19
+ socially/platforms/reddit.py,sha256=N-t0WWlIUROzuM8fJG0uVoAc3FXe8bsXgEsJlrEqo7o,2143
20
+ socially/platforms/tiktok.py,sha256=ZaZuIe_08RYaUSzCZG-TfrLCi0031EFH-TaxJawIARA,2955
21
+ socially/platforms/x.py,sha256=DPj26SyvKh7zS9oPO9kO9nm8RDhazULiO7KLpTGpRr0,6947
22
+ socially/platforms/youtube.py,sha256=WsxP_c0pMIIA6sZ1-hEnF8zBJVr7XR3geSY8ri9LHLM,3790
23
+ socially-0.2.0.dist-info/METADATA,sha256=u_OyVaJ2prwiu1rdrGydykynJxW2q4bUZGPfAcP65-4,3616
24
+ socially-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
25
+ socially-0.2.0.dist-info/licenses/LICENSE,sha256=R5pBnXRgZnK_b4Z5ZTReTIC3T7MMUYg-I9pGRQbdeEM,1065
26
+ socially-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.