feat(x): парсинг X через api.fxtwitter.com — 30 живых хендлов как источники, адаптер x_user, полный текст твитов, метрики; старый syndication-путь (429) убран

This commit is contained in:
Vlad Gothbreach
2026-10-26 19:33:35 +03:00
parent f5eef56a3b
commit cfb8639c67
4 changed files with 122 additions and 26 deletions

View File

@@ -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]