mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-08-17 18:35:41 +02:00
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""Look up archived snapshots of X/Twitter pages via the Internet Archive's
|
|
Wayback Machine CDX API (https://web.archive.org/cdx/search/cdx) — no API key
|
|
required. Useful for OSINT recovery of deleted tweets/profiles: a bare
|
|
username is expanded into a prefix search across both x.com and twitter.com
|
|
so every archived page the crawler ever saw under that profile comes back,
|
|
including individual tweet permalinks.
|
|
|
|
Each snapshot is additionally enriched by fetching the archived HTML itself
|
|
(via the Wayback "id_" identity flag, which returns the original bytes with
|
|
no toolbar/rewriting) and pulling the og:/twitter: meta tags out of it — that
|
|
is how X serves post text to link-preview crawlers, so it works even though
|
|
the live site is a JS shell.
|
|
"""
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import requests
|
|
|
|
WAYBACK_CDX_URL = "https://web.archive.org/cdx/search/cdx"
|
|
REQUEST_TIMEOUT = 30
|
|
SNAPSHOT_TIMEOUT = 10 # per-snapshot content fetch, run in parallel
|
|
ENRICH_WORKERS = 8
|
|
META_PARSE_CAP = 300_000 # bytes of HTML scanned for meta tags
|
|
|
|
|
|
class WaybackError(Exception):
|
|
pass
|
|
|
|
|
|
_DATE8_RE = re.compile(r"^\d{8}$")
|
|
_SAFE_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
|
|
|
|
|
|
def _validate_date(label: str, value: str) -> None:
|
|
if value and not _DATE8_RE.match(value):
|
|
raise WaybackError(f"{label} must be an 8-digit date (YYYYMMDD)")
|
|
|
|
|
|
def _normalize_target(raw: str) -> str:
|
|
target = (raw or "").strip()
|
|
if not target:
|
|
raise WaybackError("Target username or URL is required")
|
|
if target.startswith("http://") or target.startswith("https://"):
|
|
target = target.split("://", 1)[1]
|
|
target = target.lstrip("@")
|
|
if "/" not in target and "." not in target:
|
|
target = f"x.com/{target}"
|
|
return target
|
|
|
|
|
|
def _fetch_cdx(url: str, limit: int, from_date: str = "", to_date: str = "",
|
|
match_type: str | None = None, resume_key: str | None = None) -> tuple[list[dict], str | None]:
|
|
params = {
|
|
"url": url,
|
|
"output": "json",
|
|
"fl": "timestamp,original,statuscode,mimetype,length",
|
|
"collapse": "digest",
|
|
"limit": str(limit),
|
|
"showResumeKey": "true",
|
|
}
|
|
if match_type:
|
|
params["matchType"] = match_type
|
|
if from_date:
|
|
params["from"] = from_date
|
|
if to_date:
|
|
params["to"] = to_date
|
|
if resume_key:
|
|
params["resumeKey"] = resume_key
|
|
|
|
try:
|
|
r = requests.get(
|
|
WAYBACK_CDX_URL, params=params, timeout=REQUEST_TIMEOUT,
|
|
headers={"User-Agent": "Mozilla/5.0"},
|
|
)
|
|
r.raise_for_status()
|
|
except requests.RequestException as e:
|
|
raise WaybackError(f"Wayback CDX request failed: {e}") from e
|
|
|
|
try:
|
|
rows = r.json()
|
|
except ValueError:
|
|
return [], None
|
|
if not rows or len(rows) < 2:
|
|
return [], None
|
|
|
|
# With showResumeKey=true, a truncated page ends with an empty-array
|
|
# sentinel followed by a one-element array holding the opaque resume
|
|
# key: [header, row..., [], ["<key>"]]. A full/last page has neither.
|
|
next_resume = None
|
|
if len(rows) >= 2 and rows[-2] == [] and isinstance(rows[-1], list) and len(rows[-1]) == 1:
|
|
next_resume = rows[-1][0]
|
|
rows = rows[:-2]
|
|
|
|
header, *data_rows = rows
|
|
return [dict(zip(header, row)) for row in data_rows], next_resume
|
|
|
|
|
|
def _row_to_record(row: dict) -> dict:
|
|
ts = row.get("timestamp", "") or ""
|
|
original = row.get("original", "") or ""
|
|
iso_date = None
|
|
if len(ts) >= 14:
|
|
iso_date = f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[8:10]}:{ts[10:12]}:{ts[12:14]}"
|
|
return {
|
|
"timestamp": ts, # internal only — stripped before returning to caller
|
|
"iso_date": iso_date,
|
|
"original": original,
|
|
"statuscode": row.get("statuscode"),
|
|
"mimetype": row.get("mimetype"),
|
|
"length": row.get("length"),
|
|
"archive_url": f"https://web.archive.org/web/{ts}/{original}" if ts and original else None,
|
|
}
|
|
|
|
|
|
# ── Content enrichment ──────────────────────────────────────────────────────
|
|
|
|
_META_TAG_RE = re.compile(r"<meta\b[^>]*>", re.IGNORECASE)
|
|
_ATTR_RE = re.compile(r'''([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)\'''')
|
|
_TITLE_TAG_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
|
|
|
|
def _parse_meta_tags(text: str) -> dict:
|
|
tags = {}
|
|
for tag in _META_TAG_RE.findall(text):
|
|
attrs = {}
|
|
for m in _ATTR_RE.finditer(tag):
|
|
if m.group(1):
|
|
attrs[m.group(1).lower()] = m.group(2)
|
|
else:
|
|
attrs[m.group(3).lower()] = m.group(4)
|
|
key = attrs.get("property") or attrs.get("name")
|
|
val = attrs.get("content")
|
|
if key and val is not None:
|
|
tags[key.lower()] = val
|
|
return tags
|
|
|
|
|
|
def _extract_title_tag(text: str) -> str | None:
|
|
m = _TITLE_TAG_RE.search(text)
|
|
if not m:
|
|
return None
|
|
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
return title or None
|
|
|
|
|
|
def _fetch_snapshot_meta(timestamp: str, original: str) -> dict:
|
|
if not timestamp or not original:
|
|
return {}
|
|
snap_url = f"https://web.archive.org/web/{timestamp}id_/{original}"
|
|
try:
|
|
r = requests.get(
|
|
snap_url, timeout=SNAPSHOT_TIMEOUT,
|
|
headers={"User-Agent": "Mozilla/5.0"},
|
|
)
|
|
if r.status_code != 200 or not r.text:
|
|
return {}
|
|
except requests.RequestException:
|
|
return {}
|
|
|
|
body = r.text[:META_PARSE_CAP]
|
|
tags = _parse_meta_tags(body)
|
|
# og:/twitter: tags cover ~2012-2022 era captures; plain <meta name="description">
|
|
# and <title> are what older (pre-2012) and some modern captures fall back to.
|
|
title = tags.get("og:title") or tags.get("twitter:title") or _extract_title_tag(body)
|
|
text = tags.get("og:description") or tags.get("twitter:description") or tags.get("description")
|
|
image = tags.get("og:image") or tags.get("twitter:image")
|
|
|
|
out = {}
|
|
if title:
|
|
out["post_title"] = html.unescape(title).strip()
|
|
if text:
|
|
out["post_text"] = html.unescape(text).strip()
|
|
# Archived pages are third-party content — an og:image value could in principle
|
|
# be a "javascript:"/"data:" URI. Only ever keep it if it's a plain http(s) link,
|
|
# since the frontend renders this straight into an <a href>.
|
|
if image:
|
|
image = html.unescape(image).strip()
|
|
if _SAFE_URL_RE.match(image):
|
|
out["preview_image"] = image
|
|
return out
|
|
|
|
|
|
def _enrich_records(records: list[dict]) -> None:
|
|
"""Fetch post content for each html/200 snapshot in parallel. Mutates in place;
|
|
failures are silently skipped so one dead snapshot doesn't sink the whole search."""
|
|
candidates = [r for r in records if r.get("mimetype") == "text/html" and str(r.get("statuscode")) == "200"]
|
|
if not candidates:
|
|
return
|
|
|
|
def _job(rec):
|
|
rec.update(_fetch_snapshot_meta(rec.get("timestamp"), rec.get("original")))
|
|
|
|
with ThreadPoolExecutor(max_workers=ENRICH_WORKERS) as pool:
|
|
list(pool.map(_job, candidates))
|
|
|
|
|
|
# ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
def wayback_search(raw_target: str, count: int = 50, from_date: str = "", to_date: str = "",
|
|
cursor: str | None = None) -> tuple[list[dict], str | None]:
|
|
"""Returns (records, next_cursor). `cursor` is an opaque JSON string from
|
|
a previous call's next_cursor — pass it back to fetch the next page.
|
|
None once there's nothing more to load."""
|
|
_validate_date("from_date", from_date)
|
|
_validate_date("to_date", to_date)
|
|
target = _normalize_target(raw_target)
|
|
is_permalink = "/status/" in target
|
|
|
|
try:
|
|
incoming_cursor = json.loads(cursor) if cursor else {}
|
|
if not isinstance(incoming_cursor, dict):
|
|
incoming_cursor = {}
|
|
except ValueError:
|
|
incoming_cursor = {}
|
|
|
|
rows: list[dict] = []
|
|
outgoing_cursor: dict[str, str] = {}
|
|
multi_domain = False
|
|
|
|
if is_permalink:
|
|
page_rows, next_resume = _fetch_cdx(target, count, from_date, to_date,
|
|
resume_key=incoming_cursor.get("main"))
|
|
rows = page_rows
|
|
if next_resume:
|
|
outgoing_cursor["main"] = next_resume
|
|
else:
|
|
domain, _, path = target.partition("/")
|
|
candidates = [("x" if domain == "x.com" else "tw", target)]
|
|
alt_domain = "twitter.com" if domain == "x.com" else ("x.com" if domain == "twitter.com" else None)
|
|
if alt_domain:
|
|
candidates.append(("tw" if alt_domain == "twitter.com" else "x", f"{alt_domain}/{path}"))
|
|
|
|
# Page 1 (no incoming cursor): query every candidate domain. Later
|
|
# pages: only re-query a domain that still had a resume key on the
|
|
# previous page — a domain missing from incoming_cursor already ran
|
|
# dry, so skip it rather than restarting it from scratch.
|
|
active = [(key, url) for key, url in candidates if not incoming_cursor or key in incoming_cursor]
|
|
multi_domain = len(active) > 1
|
|
|
|
seen = set()
|
|
for key, cand in active:
|
|
page_rows, next_resume = _fetch_cdx(cand, count, from_date, to_date,
|
|
match_type="prefix", resume_key=incoming_cursor.get(key))
|
|
for row in page_rows:
|
|
dedupe_key = (row.get("timestamp"), row.get("original"))
|
|
if dedupe_key in seen:
|
|
continue
|
|
seen.add(dedupe_key)
|
|
rows.append(row)
|
|
if next_resume:
|
|
outgoing_cursor[key] = next_resume
|
|
|
|
records = [_row_to_record(r) for r in rows]
|
|
records.sort(key=lambda r: r["timestamp"], reverse=True)
|
|
# Slicing to `count` is only safe when exactly one source was queried
|
|
# this page — its own resume key already accounts for exactly its own
|
|
# raw fetch. Slicing a page that merged >1 domain would silently strand
|
|
# whatever got cut, since each domain's cursor has already moved past
|
|
# everything it returned this round.
|
|
if not multi_domain:
|
|
records = records[:count]
|
|
|
|
_enrich_records(records)
|
|
|
|
for r in records:
|
|
r.pop("timestamp", None)
|
|
|
|
next_cursor = json.dumps(outgoing_cursor) if outgoing_cursor else None
|
|
return records, next_cursor
|