feat(x): парсинг X через api.fxtwitter.com — 30 живых хендлов как источники, адаптер x_user, полный текст твитов, метрики; старый syndication-путь (429) убран
This commit is contained in:
@@ -648,7 +648,15 @@ def _models_dev(src, limit):
|
||||
return out
|
||||
|
||||
|
||||
def _x_user(src, limit=12):
|
||||
"""Лента X-аккаунта через api.fxtwitter.com (без ключей)."""
|
||||
from .providers import x as xp
|
||||
handle = (src.extra or {}).get("handle") or src.url.rsplit("/", 1)[-1]
|
||||
return xp.profile(handle, limit=max(4, min(limit, 25)))
|
||||
|
||||
|
||||
ADAPTERS = {
|
||||
"x_user": _x_user,
|
||||
"rss": _rss,
|
||||
"generic_json": _generic_json,
|
||||
"or_models": _or_models,
|
||||
|
||||
@@ -77,30 +77,64 @@ def search_x(query, limit=15, handles=None):
|
||||
extra={"handle": d["handle"]})
|
||||
items.append(it)
|
||||
|
||||
# путь 3: конкретные профили
|
||||
for h in (handles or [])[:4]:
|
||||
r = net.get(f"https://syndication.twitter.com/srv/timeline-profile/screen-name/{h}",
|
||||
headers=UA)
|
||||
if r.status != 200:
|
||||
continue
|
||||
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', r.text, re.S)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
except Exception:
|
||||
continue
|
||||
entries = (((data.get("props") or {}).get("pageProps") or {}).get("timeline") or {}).get("entries") or []
|
||||
for e in entries[:12]:
|
||||
tw = ((e.get("content") or {}).get("tweet") or {})
|
||||
txt = tw.get("full_text") or tw.get("text") or ""
|
||||
if not txt:
|
||||
continue
|
||||
tid = tw.get("id_str") or ""
|
||||
it = Item("x", "tweet", url=f"https://x.com/{h}/status/{tid}" if tid else f"https://x.com/{h}",
|
||||
title=txt[:120], text=txt, author=h, source="x.com/" + h,
|
||||
published=tw.get("created_at", ""),
|
||||
extra={"likes": tw.get("favorite_count"), "rt": tw.get("retweet_count")})
|
||||
it.score = float(tw.get("favorite_count") or 0) ** 0.5
|
||||
# путь 3: конкретные профили через fxtwitter (без ключей, отдаёт полный текст)
|
||||
for h in (handles or [])[:6]:
|
||||
for it in profile(h, limit=12):
|
||||
items.append(it)
|
||||
|
||||
# дедуп по ссылке
|
||||
uniq, seen_u = [], set()
|
||||
for it in items:
|
||||
if it.url in seen_u:
|
||||
continue
|
||||
seen_u.add(it.url)
|
||||
uniq.append(it)
|
||||
return uniq[:max(limit, 12) if handles else len(uniq)]
|
||||
|
||||
|
||||
def profile(handle, limit=20, cursor=None):
|
||||
"""Лента профиля X через api.fxtwitter.com — без токенов и логина.
|
||||
|
||||
Возвращает список Item; при ошибке сети/404 — пустой список.
|
||||
"""
|
||||
h = (handle or "").lstrip("@").strip()
|
||||
if not h:
|
||||
return []
|
||||
url = f"https://api.fxtwitter.com/2/profile/{h}/statuses"
|
||||
if cursor:
|
||||
url += "?cursor=" + urllib.parse.quote(str(cursor))
|
||||
try:
|
||||
r = net.get(url, timeout=25, json_mode=True)
|
||||
except Exception:
|
||||
return []
|
||||
if r.status != 200:
|
||||
return []
|
||||
data = r.json() or {}
|
||||
out = []
|
||||
for t in (data.get("results") or []):
|
||||
if t.get("type") != "status":
|
||||
continue
|
||||
rt = t.get("raw_text")
|
||||
txt = (rt.get("text") if isinstance(rt, dict) else rt) or t.get("text") or ""
|
||||
if not isinstance(txt, str):
|
||||
txt = str(txt)
|
||||
if len(txt) < 15:
|
||||
continue
|
||||
tid = t.get("id") or ""
|
||||
author = (t.get("author") or {}).get("screen_name") or h if isinstance(t.get("author"), dict) else h
|
||||
likes = t.get("likes") or 0
|
||||
it = Item("x", "tweet",
|
||||
url=t.get("url") or f"https://x.com/{h}/status/{tid}",
|
||||
title=txt.strip().splitlines()[0][:140], text=txt,
|
||||
author=author, source="x.com/" + h,
|
||||
published=t.get("created_at", ""),
|
||||
extra={"handle": h, "lang": t.get("lang", ""), "likes": likes, "replies": t.get("replies"),
|
||||
"reposts": t.get("reposts"), "views": t.get("views"),
|
||||
"quote": (t.get("quote") or {}).get("url") if isinstance(t.get("quote"), dict) else None})
|
||||
it.score = float(likes) ** 0.5 + float(t.get("replies") or 0) ** 0.4
|
||||
out.append(it)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
return items[:limit * 2]
|
||||
@@ -16,6 +16,7 @@ from .sources_ext3 import EXTRA3
|
||||
from .sources_abuse import ABUSE_LIVE
|
||||
from .sources_ext4 import EXTRA5
|
||||
from .sources_ext5 import EXTRA6
|
||||
from .sources_x import X_SOURCES
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -199,7 +200,7 @@ def _dedup(rows):
|
||||
return out
|
||||
|
||||
|
||||
SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6])
|
||||
SOURCES = _dedup(SOURCES_BASE + [Source(**d) for d in EXTRA] + [Source(**d) for d in EXTRA2] + [Source(**d) for d in EXTRA3] + [Source(**d) for d in ABUSE_LIVE] + [Source(**d) for d in EXTRA4] + [Source(**d) for d in EXTRA5] + [Source(**d) for d in EXTRA6] + [Source(**d) for d in X_SOURCES])
|
||||
|
||||
BY_NAME = {s.name: s for s in SOURCES}
|
||||
CATEGORIES = sorted({s.category for s in SOURCES})
|
||||
|
||||
53
omni/sources_x.py
Normal file
53
omni/sources_x.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""X/Twitter источники — читаются без API-ключей через api.fxtwitter.com.
|
||||
|
||||
Адаптер: feeds._x_user -> providers.x.profile(handle).
|
||||
Каждый хендл проверен живым прогоном (>=3 записи). Мёртвые (0 записей) не добавлены.
|
||||
"""
|
||||
|
||||
_X = [
|
||||
# (handle, category, теги)
|
||||
("vxunderground", "abuse", ("malware", "leak")),
|
||||
("malwrhunterteam", "abuse", ("malware", "stealer")),
|
||||
("DailyDarkWeb", "abuse", ("leak", "ransomware")),
|
||||
("DarkWebInformer", "abuse", ("leak", "darkweb")),
|
||||
("abuse_ch", "abuse", ("stealer", "malware")),
|
||||
("H4ckManac", "abuse", ("leak", "breach")),
|
||||
("falconfeedsio", "abuse", ("ransomware", "leak")),
|
||||
("nullcookies", "abuse", ("leak", "fraud")),
|
||||
("TheHackersNews", "security", ("news", "cve")),
|
||||
("BleepinComputer", "security", ("news", "cve")),
|
||||
("campuscodi", "security", ("news", "cve")),
|
||||
("ShadowServer", "security", ("scan", "cve")),
|
||||
("RecordedFuture", "security", ("intel", "cve")),
|
||||
("CISAgov", "security", ("cve", "advisory")),
|
||||
("msftsecintel", "security", ("cve", "advisory")),
|
||||
("Unit42_Intel", "security", ("intel", "cve")),
|
||||
("Mandiant", "security", ("intel", "cve")),
|
||||
("CrowdStrike", "security", ("intel", "cve")),
|
||||
("SentinelOne", "security", ("intel", "cve")),
|
||||
("Kaspersky", "security", ("intel", "cve")),
|
||||
("elasticlabs", "security", ("intel",)),
|
||||
("blackorbird", "security", ("apt", "intel")),
|
||||
("OpenAI", "models", ("model", "release")),
|
||||
("AnthropicAI", "models", ("model", "release")),
|
||||
("deepseek_ai", "models", ("model", "release")),
|
||||
("huggingface", "models", ("model", "dataset")),
|
||||
("AI_News_", "models", ("news", "model")),
|
||||
("karpathy", "community", ("ai", "guide")),
|
||||
("simonw", "community", ("ai", "tool")),
|
||||
("ylecun", "community", ("ai", "news")),
|
||||
]
|
||||
|
||||
X_SOURCES = [
|
||||
{
|
||||
"name": "x_" + h.lower(),
|
||||
"category": cat,
|
||||
"kind": "tweet",
|
||||
"url": "https://x.com/" + h,
|
||||
"adapter": "x_user",
|
||||
"tags": tags,
|
||||
"limit": 15,
|
||||
"extra": {"handle": h},
|
||||
}
|
||||
for h, cat, tags in _X
|
||||
]
|
||||
Reference in New Issue
Block a user