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 +30 -0
- socially/__init__.pyi +15 -0
- socially/client.py +60 -0
- socially/client.pyi +15 -0
- socially/exceptions.py +23 -0
- socially/exceptions.pyi +9 -0
- socially/http.py +80 -0
- socially/models/__init__.py +3 -0
- socially/models/user.py +29 -0
- socially/models/user.pyi +44 -0
- socially/platforms/__init__.py +40 -0
- socially/platforms/base.py +13 -0
- socially/platforms/base.pyi +8 -0
- socially/platforms/bluesky.py +40 -0
- socially/platforms/github.py +44 -0
- socially/platforms/instagram.py +63 -0
- socially/platforms/mastodon.py +53 -0
- socially/platforms/reddit.py +52 -0
- socially/platforms/tiktok.py +85 -0
- socially/platforms/x.py +187 -0
- socially/platforms/youtube.py +103 -0
- socially/py.typed +0 -0
- socially-0.2.0.dist-info/METADATA +115 -0
- socially-0.2.0.dist-info/RECORD +26 -0
- socially-0.2.0.dist-info/WHEEL +4 -0
- socially-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
socially/exceptions.pyi
ADDED
|
@@ -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)
|
socially/models/user.py
ADDED
|
@@ -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)
|
socially/models/user.pyi
ADDED
|
@@ -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,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
|
+
)
|
socially/platforms/x.py
ADDED
|
@@ -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,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.
|