edit script socmint twitter

This commit is contained in:
Jieyab89
2026-08-01 01:14:03 +07:00
parent 45be4c6a76
commit 35d3d95de0
10 changed files with 1998 additions and 34 deletions
+14 -2
View File
@@ -22,6 +22,18 @@ Xquik Dashboard
<img width="2556" height="1193" alt="image" src="https://github.com/user-attachments/assets/51e9d0f3-d079-44ce-9841-378a3e1ad7e4" />
Jieyab SOCMINT Twitter Dashboard
Dasboard Home
<img width="2556" height="1261" alt="image" src="https://github.com/user-attachments/assets/dcca42be-1257-4e15-945b-41cd3913c857" />
<img width="2546" height="1087" alt="image" src="https://github.com/user-attachments/assets/404ad67d-6b80-4448-8962-604548a3c56c" />
Archive
<img width="2558" height="1103" alt="image" src="https://github.com/user-attachments/assets/72053c57-f61b-4e7e-846d-a5d024d37e04" />
Graph
<img width="2557" height="1095" alt="image" src="https://github.com/user-attachments/assets/8046ce75-ebec-411b-91e3-786fbd0d4fe4" />
Dir Output
<img width="2312" height="790" alt="image" src="https://github.com/user-attachments/assets/334e4ab5-7ebb-46fa-82f3-be40f53d5094" />
+183 -1
View File
@@ -1,6 +1,11 @@
import json
import secrets
import shutil
import threading
from flask import Flask, jsonify, render_template, request
import requests as _req
from flask import Flask, g, jsonify, render_template, request, Response, stream_with_context, send_from_directory
import archive as _archive
from xquik_client import XquikClient, XquikError, load_config
from cookie_client import (
cookie_tweet_search,
@@ -8,6 +13,9 @@ from cookie_client import (
cookie_post_extractor,
cookie_article_extractor,
cookie_community_post_extractor,
cookie_tweet_replies,
cookie_tweet_retweeters,
cookie_geo_search,
CookieClientError,
)
@@ -19,12 +27,102 @@ ACQUIRE_TIMEOUT = 15 # seconds to wait before returning 429
_sem = threading.Semaphore(MAX_CONCURRENT_REQUESTS)
# ── Cookie & session security ─────────────────────────────────────────────────
_https = config.getboolean("server", "https", fallback=False)
app.config.update(
SECRET_KEY = config.get("server", "secret_key", fallback=secrets.token_hex(32)),
SESSION_COOKIE_HTTPONLY = True,
SESSION_COOKIE_SAMESITE = "Strict",
SESSION_COOKIE_SECURE = _https, # True only when TLS is terminated at Flask
)
# ── Security headers ──────────────────────────────────────────────────────────
@app.before_request
def _make_nonce():
g.csp_nonce = secrets.token_urlsafe(16)
@app.after_request
def _set_security_headers(response):
nonce = getattr(g, "csp_nonce", "")
csp = (
"default-src 'none'; "
f"script-src 'nonce-{nonce}' https://unpkg.com; "
"style-src 'unsafe-inline' https://unpkg.com; "
"img-src 'self' data: blob: "
"https://*.twimg.com "
"https://*.tile.openstreetmap.org "
"https://server.arcgisonline.com "
"https://*.tile.opentopomap.org "
"https://unpkg.com; "
"media-src 'self'; "
"connect-src 'self' https://nominatim.openstreetmap.org; "
"font-src 'none'; "
"frame-src 'none'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
response.headers["Content-Security-Policy"] = csp
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()"
response.headers["X-XSS-Protection"] = "0" # disable legacy broken auditor
# Harden every Set-Cookie header regardless of where it originates
raw_cookies = response.headers.getlist("Set-Cookie")
if raw_cookies:
response.headers.remove("Set-Cookie")
for raw in raw_cookies:
parts = [p.strip() for p in raw.split(";")]
flags = {p.split("=")[0].strip().lower() for p in parts[1:]}
if "httponly" not in flags:
parts.append("HttpOnly")
if "samesite" not in flags:
parts.append("SameSite=Strict")
if "secure" not in flags and _https:
parts.append("Secure")
response.headers.add("Set-Cookie", "; ".join(parts))
return response
@app.route("/")
def index():
return render_template("index.html")
# Whitelist: only proxy Twitter's video CDN to prevent SSRF
_VIDEO_CDN = ("https://video.twimg.com/",)
@app.route("/api/video")
def video_proxy():
url = request.args.get("url", "").strip()
if not any(url.startswith(prefix) for prefix in _VIDEO_CDN):
return jsonify({"ok": False, "error": "URL not allowed"}), 403
try:
upstream = _req.get(
url,
stream=True,
timeout=20,
headers={"Referer": "https://x.com/", "User-Agent": "Mozilla/5.0"},
)
headers = {"Content-Type": upstream.headers.get("Content-Type", "video/mp4")}
if "Content-Length" in upstream.headers:
headers["Content-Length"] = upstream.headers["Content-Length"]
return Response(
stream_with_context(upstream.iter_content(chunk_size=32768)),
status=upstream.status_code,
headers=headers,
)
except _req.RequestException as e:
return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/run", methods=["POST"])
def run_tool():
body = request.get_json(silent=True) or {}
@@ -74,6 +172,24 @@ def run_tool():
else:
data = XquikClient(config).post_extractor(username)
elif tool_type == "tweet_replies_extractor":
tweet_id = body.get("targetTweetId", "")
if mode != "cookie":
return jsonify({"ok": False, "error": "tweet_replies_extractor requires cookie mode"}), 400
data = cookie_tweet_replies(tweet_id, count=count, config=config)
elif tool_type == "tweet_retweeters_extractor":
tweet_id = body.get("targetTweetId", "")
if mode != "cookie":
return jsonify({"ok": False, "error": "tweet_retweeters_extractor requires cookie mode"}), 400
data = cookie_tweet_retweeters(tweet_id, count=count, config=config)
elif tool_type == "geo_post_extractor":
keyword = body.get("searchQuery", "")
if mode != "cookie":
return jsonify({"ok": False, "error": "geo_post_extractor requires cookie mode"}), 400
data = cookie_geo_search(keyword, count=count, config=config)
else:
return jsonify({"ok": False, "error": f"Unknown toolType: {tool_type}"}), 400
@@ -87,6 +203,72 @@ def run_tool():
_sem.release()
# ── Archive routes ────────────────────────────────────────────────────────────
@app.route("/graph")
def graph_viewer():
return render_template("graph.html")
@app.route("/archives")
def archive_viewer():
return render_template("archive.html")
@app.route("/api/archive/<archive_id>/results")
def archive_results(archive_id):
base = _archive.ARCHIVE_ROOT / archive_id
results_file = base / "results.json"
meta_file = base / "meta.json"
if not results_file.exists():
return jsonify({"ok": False, "error": "Archive not found"}), 404
return jsonify({
"ok": True,
"results": json.loads(results_file.read_text()),
"meta": json.loads(meta_file.read_text()) if meta_file.exists() else {},
})
@app.route("/api/archive/<archive_id>/media/<path:filename>")
def archive_media(archive_id, filename):
media_dir = _archive.ARCHIVE_ROOT / archive_id / "media"
return send_from_directory(str(media_dir), filename)
@app.route("/api/archive/<archive_id>", methods=["DELETE"])
def archive_delete(archive_id):
archive_dir = _archive.ARCHIVE_ROOT / archive_id
if not archive_dir.exists():
return jsonify({"ok": False, "error": "Archive not found"}), 404
shutil.rmtree(archive_dir)
return jsonify({"ok": True})
@app.route("/api/archive", methods=["POST"])
def archive_start():
body = request.get_json(silent=True) or {}
tool_type = body.get("toolType", "unknown")
data = body.get("data")
query_info = body.get("queryInfo", {})
if not data:
return jsonify({"ok": False, "error": "No data provided"}), 400
archive_id = _archive.start(tool_type, data, query_info)
return jsonify({"ok": True, "archiveId": archive_id})
@app.route("/api/archive/<archive_id>/status")
def archive_status(archive_id):
s = _archive.status(archive_id)
if s is None:
return jsonify({"ok": False, "error": "Archive not found"}), 404
return jsonify({"ok": True, **s})
@app.route("/api/archive/list")
def archive_list():
return jsonify({"ok": True, "archives": _archive.list_all()})
if __name__ == "__main__":
host = config.get("server", "host", fallback="127.0.0.1")
port = config.getint("server", "port", fallback=5000)
+180
View File
@@ -0,0 +1,180 @@
import json
import os
import random
import threading
import time
from datetime import datetime
from pathlib import Path
import requests
ARCHIVE_ROOT = Path(__file__).parent / "archives"
DELAY_MIN = 1.5 # seconds between media downloads (anti-ban)
DELAY_MAX = 3.5
REQUEST_TIMEOUT = 25
_registry: dict[str, dict] = {} # archive_id → status dict
_lock = threading.Lock()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_id(tool_type: str) -> str:
return f"{tool_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
def _tweet_url(item: dict) -> str | None:
tid = str(item.get("id", "")).strip()
user = str(item.get("user", "")).strip()
if tid and user:
return f"https://x.com/{user}/status/{tid}"
return None
def _media_ext(url: str, mtype: str) -> str:
if mtype in ("video", "animated_gif"):
return "mp4"
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
if ext in url.lower():
return ext.lstrip(".")
return "jpg"
def _download_file(url: str, dest: Path) -> bool:
"""Download a single file. Returns True on success."""
try:
r = requests.get(
url,
timeout=REQUEST_TIMEOUT,
headers={"Referer": "https://x.com/", "User-Agent": "Mozilla/5.0"},
stream=True,
)
r.raise_for_status()
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
for chunk in r.iter_content(chunk_size=65536):
f.write(chunk)
return True
except Exception:
return False
def _pick_fields(item: dict) -> dict:
"""Keep only the fields we want to archive."""
keys = ["id", "text", "full_text", "article_text", "user", "user_id",
"created_at", "reply_count", "retweet_count", "favorite_count",
"view_count", "in_reply_to_tweet_id", "name", "screen_name",
"description", "followers_count", "following_count",
"lat", "lon", "place",
"retweeted_text", "retweeted_by_user", "retweeted_by_name",
"retweeted_by_bio", "retweeted_at", "retweeted_tweet_id"]
return {k: item[k] for k in keys if k in item and item[k] is not None}
# ── Core archive runner (runs in background thread) ───────────────────────────
def _run(archive_id: str, tool_type: str, data, query_info: dict) -> None:
archive_dir = ARCHIVE_ROOT / archive_id
media_dir = archive_dir / "media"
archive_dir.mkdir(parents=True, exist_ok=True)
media_dir.mkdir(exist_ok=True)
items = data if isinstance(data, list) else [data]
# Build enriched records + collect media download queue
enriched = []
media_queue = [] # list of (url, dest_path)
for item in items:
if not isinstance(item, dict):
enriched.append(item)
continue
record = _pick_fields(item)
record["tweet_url"] = _tweet_url(item)
local_media = []
for midx, m in enumerate(item.get("media", [])):
url = m.get("url") or m.get("thumb", "")
if not url:
continue
mtype = m.get("type", "photo")
ext = _media_ext(url, mtype)
fname = f"{item.get('id', 'unknown')}_{mtype}_{midx}.{ext}"
dest = media_dir / fname
local_media.append(f"media/{fname}")
media_queue.append((url, dest))
if local_media:
record["archived_media"] = local_media
enriched.append(record)
# Write metadata + results immediately (no waiting on media)
meta = {
"tool": tool_type,
"query": query_info,
"archived_at": datetime.now().isoformat(timespec="seconds"),
"total_items": len(items),
"media_count": len(media_queue),
}
(archive_dir / "meta.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False)
)
(archive_dir / "results.json").write_text(
json.dumps(enriched, indent=2, ensure_ascii=False)
)
with _lock:
_registry[archive_id].update({"status": "downloading", "total": len(media_queue)})
# Download media one-by-one with anti-ban delays
for i, (url, dest) in enumerate(media_queue):
if i > 0:
time.sleep(random.uniform(DELAY_MIN, DELAY_MAX))
_download_file(url, dest)
with _lock:
_registry[archive_id]["progress"] = i + 1
with _lock:
_registry[archive_id].update({"status": "done", "path": str(archive_dir)})
# ── Public API ────────────────────────────────────────────────────────────────
def start(tool_type: str, data, query_info: dict) -> str:
"""Kick off archiving in a background thread. Returns archive_id."""
archive_id = _make_id(tool_type)
with _lock:
_registry[archive_id] = {"status": "saving", "progress": 0, "total": 0, "path": None}
t = threading.Thread(
target=_run,
args=(archive_id, tool_type, data, query_info),
daemon=True,
)
t.start()
return archive_id
def status(archive_id: str) -> dict | None:
with _lock:
entry = _registry.get(archive_id)
return dict(entry) if entry else None
def list_all() -> list[dict]:
"""Read meta.json from every archive folder, newest first."""
if not ARCHIVE_ROOT.exists():
return []
results = []
for d in sorted(ARCHIVE_ROOT.iterdir(), key=lambda p: p.name, reverse=True):
meta_file = d / "meta.json"
if not meta_file.exists():
continue
try:
meta = json.loads(meta_file.read_text())
meta["id"] = d.name
results.append(meta)
except Exception:
pass
return results
@@ -1,10 +1,8 @@
[xquik]
api_key = xxxxxxxxxxxxxxxxxxxxxxxxxxx
base_url = https://xquik.com/api/v1/extractions
[twitter_cookies]
auth_token = xxxxxxxxxxxxxxxxxx
ct0 = xxxxxxxxxxxx
+120 -25
View File
@@ -1,19 +1,3 @@
"""
cookie_client.py
Cookie-based wrapper for all five xquik tools, hitting X.com directly via
twikit/twifork — no xquik API quota consumed.
Install: pip install twifork
(NOT the upstream `twikit` package — it is broken since X changed ondemand.s.js.
twifork is a drop-in replacement; imports remain `from twikit import Client`.)
How to obtain cookies:
1. Log in to x.com in your browser.
2. Open DevTools > Application > Cookies > https://x.com
3. Copy the values of `auth_token` and `ct0`.
4. Paste them into config.ini under [twitter_cookies].
"""
import asyncio
import configparser
import os
@@ -82,16 +66,25 @@ def _extract_media(t: object) -> list:
return result
def _id_str(val) -> str | None:
"""Return ID as string, or None. Prevents 64-bit integer precision loss in JSON/JS."""
return str(val) if val is not None else None
def _tweet_to_dict(t: object) -> dict:
user_obj = getattr(t, "user", None)
d = {
"id": getattr(t, "id", None),
"id": _id_str(getattr(t, "id", None)),
"created_at": getattr(t, "created_at", None),
"text": getattr(t, "text", None),
"user": getattr(t.user, "screen_name", None) if getattr(t, "user", None) else None,
"reply_count": getattr(t, "reply_count", None),
"retweet_count": getattr(t, "retweet_count", None),
"favorite_count": getattr(t, "favorite_count", None),
"view_count": getattr(t, "view_count", None),
"user": getattr(user_obj, "screen_name", None) if user_obj else None,
"user_id": _id_str(getattr(user_obj, "id", None)) if user_obj else None,
"user_location": getattr(user_obj, "location", None) if user_obj else None,
"reply_count": getattr(t, "reply_count", None),
"retweet_count": getattr(t, "retweet_count", None),
"favorite_count": getattr(t, "favorite_count", None),
"view_count": getattr(t, "view_count", None),
"in_reply_to_tweet_id": getattr(t, "in_reply_to", None), # returns id_str directly
}
media = _extract_media(t)
if media:
@@ -101,7 +94,7 @@ def _tweet_to_dict(t: object) -> dict:
def _user_to_dict(u: object) -> dict:
return {
"id": getattr(u, "id", None),
"id": _id_str(getattr(u, "id", None)),
"name": getattr(u, "name", None),
"screen_name": getattr(u, "screen_name", None),
"description": getattr(u, "description", None),
@@ -116,6 +109,14 @@ def _user_to_dict(u: object) -> dict:
# ── Async implementations ─────────────────────────────────────────────────────
async def _resolve_user(client, identifier: str):
"""Accept either a screen_name or a numeric user ID string."""
clean = identifier.lstrip("@").strip()
if clean.isdigit():
return await client.get_user_by_id(clean)
return await client.get_user_by_screen_name(clean)
async def _tweet_search_async(query: str, auth_token: str, ct0: str, count: int) -> list:
client = await _make_client(auth_token, ct0)
results = await client.search_tweet(query, "Latest", count=count)
@@ -124,14 +125,14 @@ async def _tweet_search_async(query: str, auth_token: str, ct0: str, count: int)
async def _follower_explorer_async(username: str, auth_token: str, ct0: str, count: int) -> list:
client = await _make_client(auth_token, ct0)
user = await client.get_user_by_screen_name(username)
user = await _resolve_user(client, username)
followers = await user.get_followers(count=count)
return [_user_to_dict(u) for u in followers]
async def _post_extractor_async(username: str, auth_token: str, ct0: str, count: int) -> list:
client = await _make_client(auth_token, ct0)
user = await client.get_user_by_screen_name(username)
user = await _resolve_user(client, username)
tweets = await user.get_tweets("Tweets", count=count)
return [_tweet_to_dict(t) for t in tweets]
@@ -155,6 +156,80 @@ async def _community_posts_async(community_id: str, auth_token: str, ct0: str, c
return [_tweet_to_dict(t) for t in posts]
async def _tweet_replies_async(tweet_id: str, auth_token: str, ct0: str, count: int) -> list:
client = await _make_client(auth_token, ct0)
# Walk up the in_reply_to chain to find the conversation root.
# tweet.in_reply_to → _legacy['in_reply_to_status_id_str'] (parent tweet ID string).
# There is no conversation_id attribute on twikit Tweet objects — the only way
# to reach the root is to follow the chain until in_reply_to is None.
conversation_id = tweet_id
current_id = tweet_id
for _ in range(6): # guard: max 6 hops up the thread
try:
node = await client.get_tweet_by_id(current_id)
parent_id = getattr(node, "in_reply_to", None)
if not parent_id:
conversation_id = current_id # reached root
break
current_id = str(parent_id)
conversation_id = current_id
except Exception:
break
# Fetch all tweets in the conversation thread
results = await client.search_tweet(
f"conversation_id:{conversation_id}", "Latest", count=count
)
tweets = [_tweet_to_dict(t) for t in results]
# If the user clicked on a non-root reply, filter to that reply's direct children
if conversation_id != tweet_id:
direct = [d for d in tweets if d.get("in_reply_to_tweet_id") == tweet_id]
return direct if direct else tweets # fallback: full thread
return tweets
async def _tweet_retweeters_async(tweet_id: str, auth_token: str, ct0: str, count: int) -> list:
client = await _make_client(auth_token, ct0)
# Fetch the original tweet once so every retweeter card shows what was retweeted
rt_info: dict = {}
try:
orig = await client.get_tweet_by_id(tweet_id)
orig_user = getattr(orig, "user", None)
rt_info = {
"retweeted_text": getattr(orig, "text", None),
"retweeted_by_user": getattr(orig_user, "screen_name", None) if orig_user else None,
"retweeted_by_name": getattr(orig_user, "name", None) if orig_user else None,
"retweeted_by_bio": getattr(orig_user, "description", None) if orig_user else None,
"retweeted_at": getattr(orig, "created_at", None),
"retweeted_tweet_id": _id_str(getattr(orig, "id", None)),
}
except Exception:
pass
retweeters = await client.get_retweeters(tweet_id, count=count)
result = []
for u in retweeters:
# retweeted content first → shows prominently in the card
d = {**rt_info, **_user_to_dict(u)}
result.append(d)
return result
async def _geo_search_async(keyword: str, auth_token: str, ct0: str, count: int) -> list:
"""Keyword search; user_location (profile location string) is included in every
result so the frontend can geocode and plot it on a map."""
client = await _make_client(auth_token, ct0)
results = await client.search_tweet(keyword, "Latest", count=count)
return [_tweet_to_dict(t) for t in results]
# ── Public sync wrappers ──────────────────────────────────────────────────────
def cookie_tweet_search(query: str, count: int = 20, config: configparser.ConfigParser = None) -> list:
@@ -189,6 +264,26 @@ def cookie_community_post_extractor(
return asyncio.run(_community_posts_async(community_id, auth, ct0, count))
def cookie_tweet_replies(tweet_id: str, count: int = 50, config: configparser.ConfigParser = None) -> list:
cfg = config or load_config()
auth, ct0 = _get_creds(cfg)
return asyncio.run(_tweet_replies_async(tweet_id, auth, ct0, count))
def cookie_tweet_retweeters(tweet_id: str, count: int = 50, config: configparser.ConfigParser = None) -> list:
cfg = config or load_config()
auth, ct0 = _get_creds(cfg)
return asyncio.run(_tweet_retweeters_async(tweet_id, auth, ct0, count))
def cookie_geo_search(
keyword: str, count: int = 20, config: configparser.ConfigParser = None,
) -> list:
cfg = config or load_config()
auth, ct0 = _get_creds(cfg)
return asyncio.run(_geo_search_async(keyword, auth, ct0, count))
# Legacy alias — kept for any external scripts that import this name directly
fetch_user_timeline = cookie_post_extractor
@@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Link Bootstrap Icons CDN -->
<link rel="stylesheet" href="https://jsdelivr.net">
<title>Jieyab89 SOCMINT X — Archives</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #21253a;
--border: #2a2d3a;
--text: #e8eaf0;
--muted: #8890a4;
--accent: #5865f2;
--accent-bg: #1e2240;
--success: #22c55e;
--danger: #ef4444;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; min-height: 100vh; }
/* ── Header ── */
header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
header h1 { font-size: 14px; font-weight: 600; }
header .sep { color: var(--border); }
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
.hamburger-btn {
margin-left: auto;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
cursor: pointer;
padding: 5px 9px;
font-size: 16px;
line-height: 1;
transition: all 0.15s;
}
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
.nav-menu {
position: absolute;
top: 100%;
right: 12px;
margin-top: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px;
min-width: 175px;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
z-index: 200;
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-menu.hidden { display: none; }
.nav-menu a {
padding: 7px 12px;
border-radius: 5px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
transition: all 0.12s;
display: block;
}
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
/* ── Layout ── */
.layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 49px); }
@media (max-width: 760px) { .layout { grid-template-columns: 1fr; } }
/* ── Sidebar ── */
.sidebar {
border-right: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
}
.sidebar-head {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.sidebar-count { font-size: 11px; color: var(--muted); background: var(--bg); padding: 2px 7px; border-radius: 10px; }
.archive-list { overflow-y: auto; flex: 1; padding: 8px; }
.archive-entry {
padding: 10px 12px;
border-radius: 7px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.12s;
margin-bottom: 4px;
}
.archive-entry:hover { background: var(--surface2); border-color: var(--border); }
.archive-entry.active { background: var(--accent-bg); border-color: var(--accent); }
.ae-tool {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ae-tool .tool-pill {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--accent-bg);
color: #818cf8;
margin-right: 4px;
font-weight: 500;
}
.ae-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.ae-stats { font-size: 11px; color: var(--muted); display: flex; gap: 8px; margin-top: 3px; }
.ae-stat { display: flex; align-items: center; gap: 3px; }
.no-archives {
padding: 40px 16px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
/* ── Viewer ── */
.viewer { display: flex; flex-direction: column; overflow: hidden; }
.viewer-top {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.viewer-meta { flex: 1; }
.viewer-meta-title { font-size: 13px; font-weight: 600; }
.viewer-meta-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
.search-bar {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font);
font-size: 13px;
border-radius: 6px;
width: 220px;
transition: border-color 0.15s;
}
.search-bar:focus { outline: none; border-color: var(--accent); }
.search-bar::placeholder { color: var(--muted); }
.result-count { font-size: 12px; color: var(--muted); white-space: nowrap; }
.btn-delete {
padding: 5px 12px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-delete:hover { color: var(--danger); border-color: var(--danger); }
.cards-area { padding: 16px 18px; overflow-y: auto; flex: 1; }
.cards-grid { display: grid; gap: 8px; }
/* ── Cards ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
transition: border-color 0.12s;
}
.card:hover { border-color: #3a3d50; }
.card-row {
display: flex;
gap: 10px;
padding: 4px 0;
font-size: 13px;
border-bottom: 1px solid rgba(42,45,58,0.6);
}
.card-row:last-child { border-bottom: none; padding-bottom: 0; }
.card-row:first-child { padding-top: 0; }
.card-key {
color: var(--muted);
min-width: 100px;
flex-shrink: 0;
font-size: 11px;
padding-top: 2px;
font-weight: 500;
}
.card-val { color: var(--text); word-break: break-word; flex: 1; }
.card-val.clamp {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.card-link { color: var(--accent); text-decoration: none; font-size: 12px; }
.card-link:hover { text-decoration: underline; }
/* ── Media ── */
.card-media {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(42,45,58,0.6);
}
.media-item { position: relative; display: inline-block; }
.media-thumb {
display: block;
width: 130px;
height: 86px;
object-fit: cover;
border-radius: 5px;
border: 1px solid var(--border);
cursor: pointer;
transition: opacity 0.15s;
}
.media-thumb:hover { opacity: 0.82; }
.media-video {
display: block;
width: 220px;
height: 140px;
border-radius: 5px;
border: 1px solid var(--border);
background: #000;
}
.media-badge {
position: absolute;
bottom: 5px; left: 5px;
background: rgba(0,0,0,0.72);
color: #fff;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
pointer-events: none;
}
/* ── Empty / loading states ── */
.empty-state {
color: var(--muted);
font-size: 13px;
padding: 60px 20px;
text-align: center;
}
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<header>
<h1>Jieyab89 SOCMINT X</h1>
<span class="sep">|</span>
<span class="sub">Saved Archives</span>
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">&#9776;</button>
<nav id="navMenu" class="nav-menu hidden">
<a href="/">Home</a>
<a href="/graph">Graph</a>
<hr class="nav-divider">
<a href="/archives" class="current">Archives</a>
</nav>
</header>
<div class="layout">
<!-- Sidebar: archive list -->
<div class="sidebar">
<div class="sidebar-head">
<span class="sidebar-title">Archives</span>
<span class="sidebar-count" id="archiveCount"></span>
</div>
<div class="archive-list" id="archiveList">
<div class="no-archives"><span class="spinner"></span> Loading…</div>
</div>
</div>
<!-- Main viewer -->
<div class="viewer">
<div class="viewer-top" id="viewerTop" style="display:none">
<div class="viewer-meta">
<div class="viewer-meta-title" id="viewerTitle"></div>
<div class="viewer-meta-sub" id="viewerSub"></div>
</div>
<input type="text" class="search-bar" id="searchInput" placeholder="Search in archive…">
<span class="result-count" id="resultCount"></span>
<button class="btn-delete" id="deleteBtn">Delete</button>
</div>
<div class="cards-area">
<div id="cardsBox">
<div class="empty-state">Select an archive from the sidebar to view its contents.</div>
</div>
</div>
</div>
</div>
<script nonce="{{ g.csp_nonce }}">
// Hamburger nav
(function() {
var btn = document.getElementById('navToggle');
var menu = document.getElementById('navMenu');
btn.addEventListener('click', function(e) {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', function() { menu.classList.add('hidden'); });
menu.addEventListener('click', function(e) { e.stopPropagation(); });
}());
let allItems = [];
let activeId = null;
const archiveList = document.getElementById('archiveList');
const archiveCount = document.getElementById('archiveCount');
const viewerTop = document.getElementById('viewerTop');
const viewerTitle = document.getElementById('viewerTitle');
const viewerSub = document.getElementById('viewerSub');
const searchInput = document.getElementById('searchInput');
const resultCount = document.getElementById('resultCount');
const deleteBtn = document.getElementById('deleteBtn');
const cardsBox = document.getElementById('cardsBox');
// ── Sidebar ───────────────────────────────────────────────────────────────────
async function loadSidebar() {
const res = await fetch('/api/archive/list');
const json = await res.json();
if (!json.ok || !json.archives.length) {
archiveList.innerHTML = '<div class="no-archives">No archives yet.<br>Enable Auto Archive in the tool and run a search.</div>';
archiveCount.textContent = '0';
return;
}
archiveCount.textContent = json.archives.length;
archiveList.innerHTML = json.archives.map(a => entryHtml(a)).join('');
archiveList.querySelectorAll('.archive-entry').forEach(el => {
el.addEventListener('click', () => loadArchive(el.dataset.id));
});
// Auto-open the newest one
loadArchive(json.archives[0].id);
}
function entryHtml(a) {
const tool = (a.tool || 'unknown').replace(/_/g, ' ');
const date = a.archived_at ? a.archived_at.replace('T', ' ') : '—';
const query = queryLabel(a.query || {});
return `
<div class="archive-entry" data-id="${esc(a.id)}">
<div class="ae-tool">${esc(tool)}</div>
${query ? `<div class="ae-meta">${esc(query)}</div>` : ''}
<div class="ae-stats">
<span class="ae-stat">· ${a.total_items || 0} items</span>
<span class="ae-stat">· ${a.media_count || 0} media</span>
</div>
</div>`;
}
function queryLabel(q) {
return q.searchQuery || q.targetUsername || q.targetTweetId || q.targetCommunityId || '';
}
// ── Viewer ────────────────────────────────────────────────────────────────────
async function loadArchive(id) {
activeId = id;
// Highlight sidebar entry
archiveList.querySelectorAll('.archive-entry').forEach(el => {
el.classList.toggle('active', el.dataset.id === id);
});
cardsBox.innerHTML = '<div class="empty-state"><span class="spinner"></span> Loading…</div>';
viewerTop.style.display = 'none';
searchInput.value = '';
const res = await fetch(`/api/archive/${id}/results`);
const json = await res.json();
if (!json.ok) {
cardsBox.innerHTML = `<div class="empty-state" style="color:var(--danger)">${esc(json.error)}</div>`;
return;
}
allItems = json.results || [];
const meta = json.meta || {};
const tool = (meta.tool || 'unknown').replace(/_/g, ' ');
const query = queryLabel(meta.query || {});
viewerTitle.textContent = tool + (query ? ` — "${query}"` : '');
viewerSub.textContent = [
meta.archived_at ? meta.archived_at.replace('T', ' ') : '',
`${allItems.length} items`,
meta.media_count ? `${meta.media_count} media files` : '',
].filter(Boolean).join(' · ');
viewerTop.style.display = '';
renderCards(allItems, '');
}
// ── Search ────────────────────────────────────────────────────────────────────
searchInput.addEventListener('input', () => renderCards(allItems, searchInput.value));
function flatText(obj) {
if (!obj || typeof obj !== 'object') return String(obj ?? '');
return Object.values(obj).map(flatText).join(' ');
}
// ── Cards ─────────────────────────────────────────────────────────────────────
const PRIORITY = ['user','user_id','text','full_text','article_text','created_at','tweet_url'];
const SKIP = ['archived_media'];
const CLAMP = new Set(['text','full_text','article_text','description']);
function renderCards(items, query) {
const q = query.toLowerCase().trim();
const filtered = q ? items.filter(i => flatText(i).toLowerCase().includes(q)) : items;
resultCount.textContent = filtered.length + (q ? ' found' : ' results');
if (!filtered.length) {
cardsBox.innerHTML = '<div class="empty-state">No matching results.</div>';
return;
}
cardsBox.innerHTML = '<div class="cards-grid">' + filtered.map(buildCard).join('') + '</div>';
}
function buildCard(item) {
if (typeof item !== 'object' || !item) return '';
const entries = Object.entries(item);
const pri = entries.filter(([k]) => PRIORITY.includes(k));
const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k));
const rows = [...pri, ...rest].slice(0, 14).map(([k, v]) => {
let display;
if (v === null || v === undefined) {
display = `<span style="color:var(--muted)">—</span>`;
} else if (k === 'tweet_url') {
display = `<a href="${esc(String(v))}" target="_blank" rel="noopener" class="card-link">${esc(String(v))}</a>`;
} else if (typeof v === 'object') {
const s = JSON.stringify(v);
display = `<span style="color:var(--muted);font-size:11px">${esc(s.length > 100 ? s.slice(0,100)+'…' : s)}</span>`;
} else {
const cls = CLAMP.has(k) ? ' clamp' : '';
display = `<span class="${cls}">${esc(String(v))}</span>`;
}
return `<div class="card-row"><div class="card-key">${esc(k)}</div><div class="card-val">${display}</div></div>`;
}).join('');
const media = buildMedia(item);
return `<div class="card">${rows}${media}</div>`;
}
function buildMedia(item) {
const paths = item.archived_media;
if (!Array.isArray(paths) || !paths.length) return '';
const archiveId = activeId;
const html = paths.map(p => {
// p = "media/filename.ext"
const filename = p.replace(/^media\//, '');
const src = `/api/archive/${encodeURIComponent(archiveId)}/media/${encodeURIComponent(filename)}`;
const isVideo = filename.endsWith('.mp4');
const isGif = filename.includes('animated_gif');
if (isVideo || isGif) {
const loop = isGif ? 'loop muted' : '';
const label = isGif ? '<span class="media-badge">GIF</span>' : '';
return `<div class="media-item">
<video class="media-video" controls ${loop} preload="none">
<source src="${src}" type="video/mp4">
</video>${label}
</div>`;
}
return `<a href="${src}" target="_blank" rel="noopener" class="media-item">
<img src="${src}" class="media-thumb" alt="media" loading="lazy">
</a>`;
}).join('');
return `<div class="card-media">${html}</div>`;
}
// ── Delete ────────────────────────────────────────────────────────────────────
deleteBtn.addEventListener('click', async () => {
if (!activeId) return;
if (!confirm('Delete this archive? This will permanently remove all saved files and media.')) return;
const res = await fetch(`/api/archive/${activeId}`, { method: 'DELETE' });
const json = await res.json();
if (!json.ok) { alert('Delete failed: ' + json.error); return; }
activeId = null;
allItems = [];
cardsBox.innerHTML = '<div class="empty-state">Archive deleted. Select another from the sidebar.</div>';
viewerTop.style.display = 'none';
loadSidebar();
});
// ── Utilities ─────────────────────────────────────────────────────────────────
function esc(s) {
return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
loadSidebar();
</script>
</body>
</html>
+947
View File
@@ -0,0 +1,947 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://jsdelivr.net">
<title>Jieyab89 SOCMINT X — Graph</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #21253a;
--border: #2a2d3a;
--text: #e8eaf0;
--muted: #8890a4;
--accent: #5865f2;
--accent-bg: #1e2240;
--success: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
--purple: #8b5cf6;
--purple-bg: #1e1535;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
overflow: hidden;
}
body { display: flex; flex-direction: column; }
/* ── Header ── */
header {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
position: relative;
}
header h1 { font-size: 14px; font-weight: 600; }
header .sep { color: var(--border); }
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
.hamburger-btn {
margin-left: auto;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
cursor: pointer;
padding: 5px 9px;
font-size: 16px;
line-height: 1;
transition: all 0.15s;
}
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
.nav-menu {
position: absolute;
top: 100%;
right: 12px;
margin-top: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px;
min-width: 175px;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
z-index: 200;
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-menu.hidden { display: none; }
.nav-menu a {
padding: 7px 12px;
border-radius: 5px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
transition: all 0.12s;
display: block;
}
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
/* ── Toolbar ── */
#toolbar {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar select, #toolbar input[type="text"], #toolbar input[type="number"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 5px;
padding: 5px 8px;
font-size: 12px;
font-family: var(--font);
line-height: 1.4;
}
#toolbar select:focus, #toolbar input:focus { outline: none; border-color: var(--accent); }
#queryInput { flex: 1; min-width: 200px; }
#countInput { width: 68px; }
/* ── Buttons ── */
.btn {
padding: 5px 12px;
border-radius: 5px;
border: 1px solid var(--border);
font-size: 12px;
font-family: var(--font);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
line-height: 1.4;
}
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover:not(:disabled) { background: #4752c4; }
.btn-ghost { background: transparent; color: var(--muted); }
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
.btn-success { background: transparent; color: var(--success); border-color: #1a4d2e; }
.btn-success:hover:not(:disabled) { background: rgba(34,197,94,0.1); border-color: var(--success); }
.btn-danger { background: transparent; color: var(--danger); border-color: #4d1a1a; }
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.1); border-color: var(--danger); }
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
/* ── Main area ── */
#main {
flex: 1;
display: flex;
min-height: 0;
position: relative;
}
/* ── Cytoscape canvas ── */
#cy {
flex: 1;
min-width: 0;
background: var(--bg);
}
/* ── Info panel ── */
#infoPanel {
width: 310px;
border-left: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.2s ease;
}
#infoPanel.hidden { width: 0; border-left: none; }
#infoHeader {
padding: 9px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
gap: 8px;
}
#infoTitle {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#btnClosePanel {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
}
#btnClosePanel:hover { color: var(--text); }
#infoContent {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
font-size: 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.info-row {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.info-key {
color: var(--muted);
font-size: 10px;
min-width: 95px;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
padding-top: 1px;
line-height: 1.5;
}
.info-val {
color: var(--text);
flex: 1;
word-break: break-word;
line-height: 1.5;
}
.info-val a { color: var(--accent); text-decoration: none; }
.info-val a:hover { text-decoration: underline; }
.info-media {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.info-media a img {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
display: block;
}
.info-media a.video-link {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 60px;
background: var(--surface2);
border-radius: 4px;
border: 1px solid var(--border);
color: var(--muted);
font-size: 10px;
text-decoration: none;
}
.info-media a.video-link:hover { border-color: var(--accent); color: var(--accent); }
#infoActions {
padding: 10px 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
flex-shrink: 0;
}
#infoActions .btn { width: 100%; text-align: center; justify-content: center; }
#infoActions a.btn { text-decoration: none; display: block; }
/* ── Legend ── */
#legend {
position: absolute;
bottom: 14px;
left: 14px;
background: rgba(26,29,39,0.93);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 13px;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 5px;
pointer-events: none;
z-index: 10;
}
.leg-row { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.leg-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 2px solid;
}
.leg-icon.search { background: var(--accent); border-color: var(--accent); transform: rotate(45deg); border-radius: 1px; }
.leg-icon.tweet { background: var(--accent-bg); border-color: var(--accent); border-radius: 2px; }
.leg-icon.reply { background: rgba(34,197,94,0.12); border-color: var(--success); border-radius: 2px; }
.leg-icon.user { background: var(--purple-bg); border-color: var(--purple); border-radius: 50%; }
/* ── Status bar ── */
#statusBar {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(26,29,39,0.94);
border: 1px solid var(--border);
border-radius: 5px;
padding: 4px 16px;
font-size: 12px;
color: var(--muted);
pointer-events: none;
z-index: 20;
transition: opacity 0.3s;
max-width: 420px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#statusBar.error { color: var(--danger); border-color: #4d1a1a; }
#statusBar.hidden { opacity: 0; }
/* ── Node count badge ── */
#nodeCount {
font-size: 11px;
color: var(--muted);
padding: 3px 9px;
background: var(--bg);
border-radius: 10px;
border: 1px solid var(--border);
white-space: nowrap;
}
/* ── Spinner ── */
.spin {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Empty state hint ── */
#emptyHint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--border);
font-size: 13px;
pointer-events: none;
text-align: center;
line-height: 2;
z-index: 5;
}
#emptyHint.hidden { display: none; }
</style>
</head>
<body>
<header>
<h1>Jieyab89 SOCMINT X</h1>
<span class="sep">|</span>
<span class="sub">Graph Visualization</span>
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">&#9776;</button>
<nav id="navMenu" class="nav-menu hidden">
<a href="/">Home</a>
<a href="/graph" class="current">Graph</a>
<hr class="nav-divider">
<a href="/archives">Archives</a>
</nav>
</header>
<div id="toolbar">
<select id="toolSelect">
<option value="tweet_search_extractor">Tweet Search</option>
<option value="follower_explorer">Follower Explorer</option>
<option value="post_extractor">Post Extractor</option>
<option value="article_extractor">Article Extractor</option>
<option value="community_post_extractor">Community Posts</option>
<option value="tweet_replies_extractor">Tweet Replies [Cookie]</option>
<option value="tweet_retweeters_extractor">Tweet Retweeters [Cookie]</option>
<option value="geo_post_extractor">Geo Post Search [Cookie]</option>
</select>
<select id="modeSelect">
<option value="cookie">Cookie</option>
<option value="api">API</option>
</select>
<input id="queryInput" type="text" placeholder="Search query…" spellcheck="false" autocomplete="off">
<input id="countInput" type="number" value="20" min="1" max="200" title="Result count">
<span id="nodeCount">0 nodes</span>
<button id="btnSearch" class="btn btn-primary">Search</button>
<button id="btnArchiveAll" class="btn btn-ghost">Archive All</button>
<button id="btnDump" class="btn btn-ghost">Dump JSON</button>
<button id="btnClear" class="btn btn-danger">Clear</button>
</div>
<div id="main">
<div id="cy"></div>
<!-- Right info panel -->
<div id="infoPanel" class="hidden">
<div id="infoHeader">
<span id="infoTitle">Node Details</span>
<button id="btnClosePanel" title="Close">×</button>
</div>
<div id="infoContent"></div>
<div id="infoActions">
<button id="btnExpandReplies" class="btn btn-ghost" style="display:none">↩ Expand Replies</button>
<button id="btnExpandRetweets" class="btn btn-ghost" style="display:none">↗ Expand Retweets</button>
<a id="btnOpenTweet" class="btn btn-success" style="display:none"
href="#" target="_blank" rel="noopener noreferrer">Open Tweet ↗</a>
</div>
</div>
<!-- Status bar (floats over cy) -->
<div id="statusBar" class="hidden">Ready</div>
<!-- Empty state -->
<div id="emptyHint">
Use the toolbar to search<br>
Click any node to inspect · Click tweet nodes to expand replies / retweets
</div>
</div>
<!-- Legend (floats over cy, bottom-left) -->
<div id="legend">
<div class="leg-row"><div class="leg-icon search"></div> Search root</div>
<div class="leg-row"><div class="leg-icon tweet"></div> Tweet</div>
<div class="leg-row"><div class="leg-icon reply"></div> Reply</div>
<div class="leg-row"><div class="leg-icon user"></div> User / Retweeter</div>
</div>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
const allItems = []; // { type, item }
let selectedNode = null;
let cy;
// ── Cytoscape styles ──────────────────────────────────────────────────────────
const CY_STYLE = [
{ selector: 'node[type="search"]', style: {
shape: 'diamond',
'background-color': '#5865f2',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '11px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '9px',
width: '46px',
height: '46px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node[type="tweet"]', style: {
shape: 'roundrectangle',
'background-color': '#1e2240',
'border-color': '#5865f2',
'border-width': '2px',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '9px',
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': '138px',
width: '158px',
height: '62px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node[type="reply"]', style: {
shape: 'roundrectangle',
'background-color': '#0d2018',
'border-color': '#22c55e',
'border-width': '2px',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '9px',
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': '138px',
width: '158px',
height: '62px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node[type="retweeter"]', style: {
shape: 'ellipse',
'background-color': '#1e1535',
'border-color': '#8b5cf6',
'border-width': '2px',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '10px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '7px',
width: '74px',
height: '74px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node[type="user"]', style: {
shape: 'ellipse',
'background-color': '#1e1535',
'border-color': '#8b5cf6',
'border-width': '2px',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '10px',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': '7px',
width: '74px',
height: '74px',
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}},
{ selector: 'node:selected', style: {
'border-color': '#f59e0b',
'border-width': '3px',
'background-color': '#2a2510',
}},
{ selector: 'edge', style: {
'line-color': '#FFFFFF',
'line-style': 'dashed',
width: '1.5px',
'target-arrow-color': '#FFFFFF',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.0,
}},
];
// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
cy = cytoscape({
container: document.getElementById('cy'),
style: CY_STYLE,
layout: { name: 'preset' },
minZoom: 0.08,
maxZoom: 4,
wheelSensitivity: 0.25,
boxSelectionEnabled: false,
});
cy.on('tap', 'node', function (evt) {
selectedNode = evt.target;
showPanel(selectedNode.data());
});
cy.on('tap', function (evt) {
if (evt.target === cy) hidePanel();
});
// Hamburger nav
var navBtn = document.getElementById('navToggle');
var navMenu = document.getElementById('navMenu');
navBtn.addEventListener('click', function(e) {
e.stopPropagation();
navMenu.classList.toggle('hidden');
});
document.addEventListener('click', function() { navMenu.classList.add('hidden'); });
navMenu.addEventListener('click', function(e) { e.stopPropagation(); });
document.getElementById('btnSearch').addEventListener('click', runSearch);
document.getElementById('queryInput').addEventListener('keydown', function (e) {
if (e.key === 'Enter') runSearch();
});
document.getElementById('toolSelect').addEventListener('change', updatePlaceholder);
document.getElementById('btnArchiveAll').addEventListener('click', archiveAll);
document.getElementById('btnDump').addEventListener('click', dumpJSON);
document.getElementById('btnClear').addEventListener('click', clearGraph);
document.getElementById('btnClosePanel').addEventListener('click', hidePanel);
document.getElementById('btnExpandReplies').addEventListener('click', function () {
expandNode('tweet_replies_extractor');
});
document.getElementById('btnExpandRetweets').addEventListener('click', function () {
expandNode('tweet_retweeters_extractor');
});
updatePlaceholder();
}
// ── Placeholder by tool type ──────────────────────────────────────────────────
function updatePlaceholder() {
var map = {
tweet_search_extractor: 'Search query…',
follower_explorer: 'Username or user ID…',
post_extractor: 'Username or user ID…',
article_extractor: 'Tweet ID…',
community_post_extractor: 'Community ID…',
tweet_replies_extractor: 'Tweet ID…',
tweet_retweeters_extractor: 'Tweet ID…',
geo_post_extractor: 'Keyword (geocodes profile location)…',
};
var tool = document.getElementById('toolSelect').value;
document.getElementById('queryInput').placeholder = map[tool] || 'Query…';
}
// ── Status bar ────────────────────────────────────────────────────────────────
var _statusTimer = null;
function setStatus(msg, isError, persist) {
var bar = document.getElementById('statusBar');
bar.textContent = msg;
bar.classList.toggle('error', !!isError);
bar.classList.remove('hidden');
if (_statusTimer) clearTimeout(_statusTimer);
if (!persist) {
_statusTimer = setTimeout(function () { bar.classList.add('hidden'); }, 4500);
}
}
function updateNodeCount() {
var n = cy.nodes().length;
document.getElementById('nodeCount').textContent = n + ' node' + (n !== 1 ? 's' : '');
document.getElementById('emptyHint').classList.toggle('hidden', n > 0);
}
// ── API helpers ───────────────────────────────────────────────────────────────
function buildBody(tool, query, count, mode) {
var body = { toolType: tool, mode: mode, count: count };
if (tool === 'tweet_search_extractor' || tool === 'geo_post_extractor') {
body.searchQuery = query;
} else if (tool === 'follower_explorer' || tool === 'post_extractor') {
body.targetUsername = query;
} else if (
tool === 'article_extractor' ||
tool === 'tweet_replies_extractor' ||
tool === 'tweet_retweeters_extractor'
) {
body.targetTweetId = query;
} else if (tool === 'community_post_extractor') {
body.targetCommunityId = query;
}
return body;
}
async function apiFetch(body) {
var res = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var json = await res.json();
if (!json.ok) throw new Error(json.error || 'API error');
return json.data;
}
// ── Label builder ─────────────────────────────────────────────────────────────
function makeLabel(item, type) {
if (type === 'user' || type === 'retweeter') {
var handle = item.screen_name || item.user || '';
return handle ? '@' + handle : 'Unknown';
}
var text = (item.text || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
return text.length > 60 ? text.substring(0, 60) + '…' : (text || ('Tweet ' + (item.id || '?')));
}
// ── Graph operations ──────────────────────────────────────────────────────────
function addNodes(items, nodeType, parentId) {
var added = 0;
items.forEach(function (item, i) {
var tweetId = String(item.id || '');
var cyId = tweetId ? 'n_' + tweetId : 'n_' + Date.now() + '_' + i;
if (cy.getElementById(cyId).length) {
if (parentId) addEdge(parentId, cyId);
return;
}
cy.add({
data: {
id: cyId,
type: nodeType,
label: makeLabel(item, nodeType),
tweetId: tweetId,
raw: item,
}
});
if (parentId) addEdge(parentId, cyId);
allItems.push({ type: nodeType, item: item });
added++;
});
return added;
}
function addEdge(src, tgt) {
var eId = 'e_' + src + '_' + tgt;
if (!cy.getElementById(eId).length) {
cy.add({ data: { id: eId, source: src, target: tgt } });
}
}
function runLayout(fit, randomize) {
cy.layout({
name: 'cose',
animate: true,
animationDuration: 450,
randomize: !!randomize,
fit: !!fit,
nodeRepulsion: 10000,
idealEdgeLength: 130,
gravity: 0.35,
numIter: 1000,
padding: 40,
}).run();
}
// ── Search ────────────────────────────────────────────────────────────────────
async function runSearch() {
var tool = document.getElementById('toolSelect').value;
var query = document.getElementById('queryInput').value.trim();
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 20));
var mode = document.getElementById('modeSelect').value;
if (!query) { setStatus('Enter a query first', true); return; }
document.getElementById('btnSearch').disabled = true;
setStatus('Searching…', false, true);
try {
var data = await apiFetch(buildBody(tool, query, count, mode));
var items = Array.isArray(data) ? data : [data];
// Central search node
var searchId = 'search_' + Date.now();
var shortQ = query.length > 40 ? query.substring(0, 40) + '…' : query;
cy.add({ data: { id: searchId, type: 'search', label: shortQ, tweetId: '', raw: { query: query } } });
// Node type by tool
var nodeType = 'tweet';
if (tool === 'follower_explorer') nodeType = 'user';
else if (tool === 'tweet_retweeters_extractor') nodeType = 'retweeter';
var added = addNodes(items, nodeType, searchId);
runLayout(true, true);
updateNodeCount();
setStatus('Done — ' + items.length + ' result(s) (' + added + ' new nodes)');
} catch (e) {
setStatus('Error: ' + e.message, true);
} finally {
document.getElementById('btnSearch').disabled = false;
}
}
// ── Expand replies / retweets from selected node ──────────────────────────────
async function expandNode(expandTool) {
if (!selectedNode) return;
var data = selectedNode.data();
var tweetId = data.tweetId;
if (!tweetId) { setStatus('Node has no tweet ID', true); return; }
var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
var parentId = selectedNode.id();
document.getElementById('btnExpandReplies').disabled = true;
document.getElementById('btnExpandRetweets').disabled = true;
setStatus('Expanding…', false, true);
try {
var fetched = await apiFetch({ toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId });
var items = Array.isArray(fetched) ? fetched : [fetched];
var nodeType = expandTool === 'tweet_replies_extractor' ? 'reply' : 'retweeter';
var added = addNodes(items, nodeType, parentId);
runLayout(false, false);
updateNodeCount();
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
} catch (e) {
setStatus('Error: ' + e.message, true);
} finally {
document.getElementById('btnExpandReplies').disabled = false;
document.getElementById('btnExpandRetweets').disabled = false;
}
}
// ── Info panel ────────────────────────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
var PRIORITY_KEYS = [
'user', 'screen_name', 'name', 'text', 'created_at',
'retweeted_by_user', 'retweeted_by_name', 'retweeted_text', 'retweeted_by_bio',
'reply_count', 'retweet_count', 'favorite_count', 'view_count',
'followers_count', 'following_count', 'tweet_count',
'description', 'user_location', 'in_reply_to_tweet_id',
'retweeted_tweet_id', 'retweeted_at', 'verified', 'is_blue_verified',
];
var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id']);
function showPanel(data) {
var raw = data.raw || {};
var type = data.type;
var titles = { search: 'Search Node', tweet: 'Tweet', reply: 'Reply', user: 'User', retweeter: 'Retweeter' };
document.getElementById('infoTitle').textContent = titles[type] || 'Node';
// Build key list (priority first, then remaining)
var allKeys = Object.keys(raw);
var ordered = PRIORITY_KEYS.concat(allKeys.filter(function (k) { return PRIORITY_KEYS.indexOf(k) === -1; }));
var keys = ordered.filter(function (k) {
return !SKIP_KEYS.has(k) && raw[k] !== null && raw[k] !== undefined && raw[k] !== '';
});
var rows = keys.map(function (k) {
var v = raw[k];
var val = esc(String(v));
// Format tweet ID fields as links
if ((k === 'retweeted_tweet_id' || k === 'in_reply_to_tweet_id') && /^\d+$/.test(String(v))) {
var handle = raw.retweeted_by_user || raw.user || '';
if (handle) {
val = '<a href="https://x.com/' + encodeURIComponent(handle) + '/status/' + encodeURIComponent(String(v)) +
'" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + ' ↗</a>';
}
}
var label = esc(k.replace(/_/g, ' '));
return '<div class="info-row"><div class="info-key">' + label + '</div><div class="info-val">' + val + '</div></div>';
}).join('');
// Media thumbnails
var media = Array.isArray(raw.media) ? raw.media : [];
if (media.length) {
var mediaParts = ['<div class="info-media">'];
media.forEach(function (m) {
var thumb = m.thumb || '';
var url = m.url || thumb;
var mtype = m.type || 'photo';
if (!thumb) return;
if (mtype === 'photo') {
mediaParts.push(
'<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
'<img src="' + esc(thumb) + '" loading="lazy" alt="media"></a>'
);
} else {
mediaParts.push(
'<a href="/api/video?url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" class="video-link">▶ ' + esc(mtype) + '</a>'
);
}
});
mediaParts.push('</div>');
rows += mediaParts.join('');
}
document.getElementById('infoContent').innerHTML = rows;
// Action buttons
var isTweet = type === 'tweet' || type === 'reply';
var tweetId = data.tweetId;
var user = raw.user || raw.screen_name || '';
document.getElementById('btnExpandReplies').style.display = isTweet ? '' : 'none';
document.getElementById('btnExpandRetweets').style.display = isTweet ? '' : 'none';
var openBtn = document.getElementById('btnOpenTweet');
if (isTweet && tweetId && user) {
openBtn.href = 'https://x.com/' + encodeURIComponent(user) + '/status/' + encodeURIComponent(tweetId);
openBtn.style.display = '';
} else {
openBtn.style.display = 'none';
}
document.getElementById('infoPanel').classList.remove('hidden');
}
function hidePanel() {
document.getElementById('infoPanel').classList.add('hidden');
selectedNode = null;
cy.elements().unselect();
}
// ── Archive All ───────────────────────────────────────────────────────────────
async function archiveAll() {
if (!allItems.length) { setStatus('Nothing to archive yet', true); return; }
var query = document.getElementById('queryInput').value.trim();
var tool = document.getElementById('toolSelect').value;
setStatus('Archiving…', false, true);
try {
var res = await fetch('/api/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolType: 'graph_' + tool,
data: allItems.map(function (d) { return d.item; }),
queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
}),
});
var json = await res.json();
if (!json.ok) throw new Error(json.error);
setStatus('Archived — ID: ' + json.archiveId);
} catch (e) {
setStatus('Archive error: ' + e.message, true);
}
}
// ── Dump JSON ─────────────────────────────────────────────────────────────────
function dumpJSON() {
if (!allItems.length) { setStatus('Nothing to export yet', true); return; }
var payload = {
exported_at: new Date().toISOString(),
query: document.getElementById('queryInput').value.trim(),
tool: document.getElementById('toolSelect').value,
node_count: cy.nodes().length,
items: allItems,
};
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'graph_export_' + Date.now() + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('Exported ' + allItems.length + ' item(s)');
}
// ── Clear ─────────────────────────────────────────────────────────────────────
function clearGraph() {
cy.elements().remove();
allItems.length = 0;
hidePanel();
updateNodeCount();
setStatus('Graph cleared');
}
// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>
-2
View File
@@ -46,8 +46,6 @@ class XquikClient:
except ValueError as e:
raise XquikError(f"Response bukan JSON valid: {resp.text[:300]}") from e
# --- 5 tool type sesuai docs ---
def tweet_search(self, search_query: str) -> dict:
return self._post({"toolType": "tweet_search_extractor", "searchQuery": search_query})
@@ -53,6 +53,7 @@ def green(text):
# Wordlist (TLD + ccTLD + gTLD + vanity)
COMMON_TLDS = [
# Arr extentions tld domain lists
# All region goverment
"gov","mil","go.id","mil.id","gov.au","gov.br",
@@ -327,8 +328,7 @@ COMMON_TLDS = [
"xn--t60b56a","xn--tckwe","xn--tiq49xqyj","xn--unup4y","xn--vermgensberater-ctb","xn--vermgensberatung-pwb","xn--vhquv","xn--vuq861b",
"xn--w4r85el8fhu5dnra","xn--w4rs40l","xn--wgbh1c","xn--wgbl6a","xn--xhq521b","xn--xkc2al3hye2a","xn--xkc2dl3a5ee0h","xn--y9a3aq","xn--yfro4i67o",
"xn--ygbi2ammx","xn--zfr164b","xxx","xyz","yachts","yahoo","yamaxun","yandex","ye","yodobashi","yoga","yokohama","you","youtube","yun","za",
"zappos","zara","zero","zm","zone","zuerich","zw",
"zappos","zara","zero","zm","zone","zuerich","zw",
]
def generate_brute_tlds(max_len=3, min_len=1):