mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-09-27 04:04:51 +02:00
update scripts for socmint twitter
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Jieyab ft Xquik
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Edit `config.ini.example` to config.ini
|
||||
|
||||
## Run Local Web Server
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:5000`
|
||||
|
||||
# Results
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import threading
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
from xquik_client import XquikClient, XquikError, load_config
|
||||
from cookie_client import (
|
||||
cookie_tweet_search,
|
||||
cookie_follower_explorer,
|
||||
cookie_post_extractor,
|
||||
cookie_article_extractor,
|
||||
cookie_community_post_extractor,
|
||||
CookieClientError,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
config = load_config()
|
||||
|
||||
MAX_CONCURRENT_REQUESTS = 3 # parallel execution slots
|
||||
ACQUIRE_TIMEOUT = 15 # seconds to wait before returning 429
|
||||
|
||||
_sem = threading.Semaphore(MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/run", methods=["POST"])
|
||||
def run_tool():
|
||||
body = request.get_json(silent=True) or {}
|
||||
tool_type = body.get("toolType")
|
||||
mode = body.get("mode", "api") # "api" | "cookie"
|
||||
count = max(1, min(int(body.get("count", 20)), 200))
|
||||
|
||||
if not _sem.acquire(blocking=True, timeout=ACQUIRE_TIMEOUT):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "Server is busy — max concurrent requests reached. Please try again shortly.",
|
||||
}), 429
|
||||
|
||||
try:
|
||||
if tool_type == "tweet_search_extractor":
|
||||
query = body.get("searchQuery", "")
|
||||
if mode == "cookie":
|
||||
data = cookie_tweet_search(query, count=count, config=config)
|
||||
else:
|
||||
data = XquikClient(config).tweet_search(query)
|
||||
|
||||
elif tool_type == "follower_explorer":
|
||||
username = body.get("targetUsername", "")
|
||||
if mode == "cookie":
|
||||
data = cookie_follower_explorer(username, count=count, config=config)
|
||||
else:
|
||||
data = XquikClient(config).follower_explorer(username)
|
||||
|
||||
elif tool_type == "article_extractor":
|
||||
tweet_id = body.get("targetTweetId", "")
|
||||
if mode == "cookie":
|
||||
data = cookie_article_extractor(tweet_id, config=config)
|
||||
else:
|
||||
data = XquikClient(config).article_extractor(tweet_id)
|
||||
|
||||
elif tool_type == "community_post_extractor":
|
||||
community_id = body.get("targetCommunityId", "")
|
||||
if mode == "cookie":
|
||||
data = cookie_community_post_extractor(community_id, count=count, config=config)
|
||||
else:
|
||||
data = XquikClient(config).community_post_extractor(community_id)
|
||||
|
||||
elif tool_type == "post_extractor":
|
||||
username = body.get("targetUsername", "")
|
||||
if mode == "cookie":
|
||||
data = cookie_post_extractor(username, count=count, config=config)
|
||||
else:
|
||||
data = XquikClient(config).post_extractor(username)
|
||||
|
||||
else:
|
||||
return jsonify({"ok": False, "error": f"Unknown toolType: {tool_type}"}), 400
|
||||
|
||||
return jsonify({"ok": True, "data": data})
|
||||
|
||||
except (XquikError, CookieClientError) as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": f"Unexpected error: {e}"}), 500
|
||||
finally:
|
||||
_sem.release()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = config.get("server", "host", fallback="127.0.0.1")
|
||||
port = config.getint("server", "port", fallback=5000)
|
||||
debug = config.getboolean("server", "debug", fallback=True)
|
||||
app.run(host=host, port=port, debug=debug, threaded=True)
|
||||
@@ -0,0 +1,14 @@
|
||||
[xquik]
|
||||
|
||||
api_key = xxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
base_url = https://xquik.com/api/v1/extractions
|
||||
|
||||
[twitter_cookies]
|
||||
|
||||
auth_token = xxxxxxxxxxxxxxxxxx
|
||||
ct0 = xxxxxxxxxxxx
|
||||
|
||||
[server]
|
||||
host = 127.0.0.1
|
||||
port = 5000
|
||||
debug = true
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
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
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
|
||||
|
||||
|
||||
class CookieClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str = CONFIG_PATH) -> configparser.ConfigParser:
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path)
|
||||
return cfg
|
||||
|
||||
|
||||
def _get_creds(config: configparser.ConfigParser) -> tuple[str, str]:
|
||||
auth_token = config.get("twitter_cookies", "auth_token", fallback="").strip()
|
||||
ct0 = config.get("twitter_cookies", "ct0", fallback="").strip()
|
||||
if not auth_token or not ct0:
|
||||
raise CookieClientError(
|
||||
"auth_token / ct0 are not set in config.ini [twitter_cookies]"
|
||||
)
|
||||
return auth_token, ct0
|
||||
|
||||
|
||||
async def _make_client(auth_token: str, ct0: str):
|
||||
try:
|
||||
from twikit import Client
|
||||
except ImportError as e:
|
||||
raise CookieClientError(
|
||||
"Required package not installed. Run: pip install twifork "
|
||||
"(not `twikit` — the upstream package is broken due to X's ondemand.s.js change)"
|
||||
) from e
|
||||
client = Client(language="en-US")
|
||||
client.set_cookies({"auth_token": auth_token, "ct0": ct0})
|
||||
return client
|
||||
|
||||
|
||||
# ── Serialisers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_media(t: object) -> list:
|
||||
"""Return [{type, thumb, url}] for each media item attached to a tweet."""
|
||||
result = []
|
||||
for m in getattr(t, "media", None) or []:
|
||||
mtype = getattr(m, "type", "photo")
|
||||
thumb = (
|
||||
getattr(m, "media_url_https", None)
|
||||
or getattr(m, "media_url", None)
|
||||
or getattr(m, "url", None)
|
||||
)
|
||||
if not thumb:
|
||||
continue
|
||||
url = thumb
|
||||
if mtype in ("video", "animated_gif"):
|
||||
vid = getattr(m, "video_info", None)
|
||||
if isinstance(vid, dict):
|
||||
mp4s = [
|
||||
v for v in vid.get("variants", [])
|
||||
if isinstance(v, dict) and v.get("content_type") == "video/mp4"
|
||||
]
|
||||
if mp4s:
|
||||
url = max(mp4s, key=lambda v: v.get("bitrate", 0)).get("url", thumb)
|
||||
result.append({"type": mtype, "thumb": thumb, "url": url})
|
||||
return result
|
||||
|
||||
|
||||
def _tweet_to_dict(t: object) -> dict:
|
||||
d = {
|
||||
"id": 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),
|
||||
}
|
||||
media = _extract_media(t)
|
||||
if media:
|
||||
d["media"] = media
|
||||
return d
|
||||
|
||||
|
||||
def _user_to_dict(u: object) -> dict:
|
||||
return {
|
||||
"id": getattr(u, "id", None),
|
||||
"name": getattr(u, "name", None),
|
||||
"screen_name": getattr(u, "screen_name", None),
|
||||
"description": getattr(u, "description", None),
|
||||
"followers_count": getattr(u, "followers_count", None),
|
||||
"following_count": getattr(u, "following_count", None),
|
||||
"tweet_count": getattr(u, "statuses_count", None),
|
||||
"created_at": getattr(u, "created_at", None),
|
||||
"verified": getattr(u, "verified", None),
|
||||
"is_blue_verified": getattr(u, "is_blue_verified", None),
|
||||
}
|
||||
|
||||
|
||||
# ── Async implementations ─────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
return [_tweet_to_dict(t) for t in results]
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
tweets = await user.get_tweets("Tweets", count=count)
|
||||
return [_tweet_to_dict(t) for t in tweets]
|
||||
|
||||
|
||||
async def _article_extractor_async(tweet_id: str, auth_token: str, ct0: str) -> dict:
|
||||
client = await _make_client(auth_token, ct0)
|
||||
tweet = await client.get_tweet_by_id(tweet_id)
|
||||
result = _tweet_to_dict(tweet)
|
||||
note = getattr(tweet, "note_tweet", None)
|
||||
if note:
|
||||
result["article_text"] = note
|
||||
card = getattr(tweet, "card", None)
|
||||
if card:
|
||||
result["card"] = str(card)
|
||||
return result
|
||||
|
||||
|
||||
async def _community_posts_async(community_id: str, auth_token: str, ct0: str, count: int) -> list:
|
||||
client = await _make_client(auth_token, ct0)
|
||||
posts = await client.get_community_tweets(community_id, "Latest", count=count)
|
||||
return [_tweet_to_dict(t) for t in posts]
|
||||
|
||||
|
||||
# ── Public sync wrappers ──────────────────────────────────────────────────────
|
||||
|
||||
def cookie_tweet_search(query: str, count: int = 20, config: configparser.ConfigParser = None) -> list:
|
||||
cfg = config or load_config()
|
||||
auth, ct0 = _get_creds(cfg)
|
||||
return asyncio.run(_tweet_search_async(query, auth, ct0, count))
|
||||
|
||||
|
||||
def cookie_follower_explorer(username: str, count: int = 20, config: configparser.ConfigParser = None) -> list:
|
||||
cfg = config or load_config()
|
||||
auth, ct0 = _get_creds(cfg)
|
||||
return asyncio.run(_follower_explorer_async(username, auth, ct0, count))
|
||||
|
||||
|
||||
def cookie_post_extractor(username: str, count: int = 20, config: configparser.ConfigParser = None) -> list:
|
||||
cfg = config or load_config()
|
||||
auth, ct0 = _get_creds(cfg)
|
||||
return asyncio.run(_post_extractor_async(username, auth, ct0, count))
|
||||
|
||||
|
||||
def cookie_article_extractor(tweet_id: str, config: configparser.ConfigParser = None) -> dict:
|
||||
cfg = config or load_config()
|
||||
auth, ct0 = _get_creds(cfg)
|
||||
return asyncio.run(_article_extractor_async(tweet_id, auth, ct0))
|
||||
|
||||
|
||||
def cookie_community_post_extractor(
|
||||
community_id: str, count: int = 20, config: configparser.ConfigParser = None
|
||||
) -> list:
|
||||
cfg = config or load_config()
|
||||
auth, ct0 = _get_creds(cfg)
|
||||
return asyncio.run(_community_posts_async(community_id, auth, ct0, count))
|
||||
|
||||
|
||||
# Legacy alias — kept for any external scripts that import this name directly
|
||||
fetch_user_timeline = cookie_post_extractor
|
||||
|
||||
|
||||
# ── CLI entry-point ───────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(description="Cookie-based X data extraction CLI")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("tweet_search").add_argument("query")
|
||||
|
||||
p = sub.add_parser("follower_explorer")
|
||||
p.add_argument("username")
|
||||
p.add_argument("--count", type=int, default=20)
|
||||
|
||||
p = sub.add_parser("post_extractor")
|
||||
p.add_argument("username")
|
||||
p.add_argument("--count", type=int, default=20)
|
||||
|
||||
sub.add_parser("article_extractor").add_argument("tweet_id")
|
||||
|
||||
p = sub.add_parser("community_post_extractor")
|
||||
p.add_argument("community_id")
|
||||
p.add_argument("--count", type=int, default=20)
|
||||
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.cmd == "tweet_search":
|
||||
out = cookie_tweet_search(args.query)
|
||||
elif args.cmd == "follower_explorer":
|
||||
out = cookie_follower_explorer(args.username, count=args.count)
|
||||
elif args.cmd == "post_extractor":
|
||||
out = cookie_post_extractor(args.username, count=args.count)
|
||||
elif args.cmd == "article_extractor":
|
||||
out = cookie_article_extractor(args.tweet_id)
|
||||
elif args.cmd == "community_post_extractor":
|
||||
out = cookie_community_post_extractor(args.community_id, count=args.count)
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
except CookieClientError as e:
|
||||
print(f"[ERROR] {e}")
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,3 @@
|
||||
flask
|
||||
requests
|
||||
twifork
|
||||
@@ -0,0 +1,550 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jieyab89 SOCMINT Twitter / X</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e8eaf0;
|
||||
--muted: #8890a4;
|
||||
--accent: #5865f2;
|
||||
--accent-bg: #1e2240;
|
||||
--success: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--warn: #f59e0b;
|
||||
--cookie: #8b5cf6;
|
||||
--cookie-bg: #1e1535;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", 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 {
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
header h1 { font-size: 14px; font-weight: 600; letter-spacing: 0.02em; }
|
||||
header .sep { color: var(--border); }
|
||||
header .sub { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 290px 1fr;
|
||||
min-height: calc(100vh - 49px);
|
||||
}
|
||||
@media (max-width: 720px) { .layout { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.field { margin-bottom: 13px; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
select, input[type="text"], input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s;
|
||||
appearance: none;
|
||||
}
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%238890a4'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
select:focus, input:focus { outline: none; border-color: var(--accent); }
|
||||
.tool-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
/* Mode toggle */
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mode-toggle button {
|
||||
flex: 1;
|
||||
padding: 7px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.mode-toggle button[data-mode="api"].active { background: var(--accent-bg); color: #818cf8; font-weight: 500; }
|
||||
.mode-toggle button[data-mode="cookie"].active { background: var(--cookie-bg); color: #a78bfa; font-weight: 500; }
|
||||
.mode-hint { font-size: 11px; color: var(--muted); margin-top: 5px; }
|
||||
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
|
||||
.btn-run {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 4px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-run:disabled { opacity: 0.45; cursor: wait; }
|
||||
.btn-run:not(:disabled):hover { opacity: 0.88; }
|
||||
|
||||
/* Output */
|
||||
.output { padding: 18px; overflow: auto; }
|
||||
|
||||
.output-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge.ok { color: var(--success); border-color: #14532d; background: #052011; }
|
||||
.badge.err { color: var(--danger); border-color: #7f1d1d; background: #1c0505; }
|
||||
.badge.running { color: var(--warn); border-color: #78350f; background: #1c0e02; }
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
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); flex-shrink: 0; }
|
||||
|
||||
/* Cards */
|
||||
.cards-grid { display: grid; gap: 8px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Media (gambar / video) */
|
||||
.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-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;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Jieyab89 SOCMINT X</h1>
|
||||
<span class="sep">|</span>
|
||||
<span class="sub">Retrieve data using Xquik API or Cookie</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
|
||||
<div class="field">
|
||||
<label>Tool</label>
|
||||
<select id="toolType">
|
||||
<option value="tweet_search_extractor">tweet search extractor</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 post extractor</option>
|
||||
</select>
|
||||
<div id="toolHint" class="tool-hint"></div>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div id="dynamicFields"></div>
|
||||
|
||||
<button class="btn-run" id="runBtn">Run</button>
|
||||
</div>
|
||||
|
||||
<div class="output">
|
||||
<div class="output-top">
|
||||
<div id="statusBadge" class="badge">idle</div>
|
||||
<input type="text" class="search-bar" id="searchInput" placeholder="Search......" style="display:none">
|
||||
<div class="result-count" id="resultCount"></div>
|
||||
</div>
|
||||
<div id="resultBox">
|
||||
<div class="empty-state">Nothing found :(</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const toolType = document.getElementById('toolType');
|
||||
const dynFields = document.getElementById('dynamicFields');
|
||||
const runBtn = document.getElementById('runBtn');
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
const resultBox = document.getElementById('resultBox');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const resultCount = document.getElementById('resultCount');
|
||||
const toolHint = document.getElementById('toolHint');
|
||||
|
||||
let currentMode = 'api';
|
||||
let currentData = null;
|
||||
|
||||
const HINTS = {
|
||||
tweet_search_extractor: 'Search for tweets by keyword',
|
||||
follower_explorer: 'List followers of an account',
|
||||
post_extractor: 'Tweet timeline from an account',
|
||||
article_extractor: 'Content of an X article from its tweet URL',
|
||||
community_post_extractor: 'Posts from an X Community',
|
||||
};
|
||||
|
||||
const HAS_COUNT = new Set([
|
||||
'tweet_search_extractor',
|
||||
'follower_explorer',
|
||||
'post_extractor',
|
||||
'community_post_extractor',
|
||||
]);
|
||||
|
||||
function modeHintText() {
|
||||
return currentMode === 'cookie'
|
||||
? 'Fetches directly from X.com using your cookie session — no xquik quota used.'
|
||||
: 'Uses your xquik API key quota.';
|
||||
}
|
||||
|
||||
function renderFields() {
|
||||
const t = toolType.value;
|
||||
toolHint.textContent = HINTS[t] || '';
|
||||
|
||||
// Mode toggle — shown for every tool
|
||||
let html = `
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<div class="mode-toggle" id="modeToggle">
|
||||
<button type="button" data-mode="api" class="${currentMode === 'api' ? 'active' : ''}">xquik API</button>
|
||||
<button type="button" data-mode="cookie" class="${currentMode === 'cookie' ? 'active' : ''}">Cookie</button>
|
||||
</div>
|
||||
<div class="mode-hint" id="modeHint">${modeHintText()}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Tool-specific input fields
|
||||
if (t === 'tweet_search_extractor') {
|
||||
html += field('searchQuery', 'Search query', 'bitcoin');
|
||||
} else if (t === 'follower_explorer') {
|
||||
html += field('targetUsername', 'Target username', 'elonmusk');
|
||||
} else if (t === 'article_extractor') {
|
||||
html += field('targetTweetId', 'Tweet ID', '1234567890');
|
||||
} else if (t === 'community_post_extractor') {
|
||||
html += field('targetCommunityId', 'Community ID', '1234567890');
|
||||
} else if (t === 'post_extractor') {
|
||||
html += field('targetUsername', 'Target username', 'elonmusk');
|
||||
}
|
||||
|
||||
// Count — only for tools that support it, visible in cookie mode only
|
||||
if (HAS_COUNT.has(t)) {
|
||||
html += `
|
||||
<div class="field" id="countWrap" style="${currentMode === 'cookie' ? '' : 'display:none'}">
|
||||
<label>Result count (max 200)</label>
|
||||
<input id="countInput" type="number" value="20" min="1" max="200">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
dynFields.innerHTML = html;
|
||||
attachModeListeners();
|
||||
}
|
||||
|
||||
function field(id, lbl, ph) {
|
||||
return `<div class="field"><label>${lbl}</label><input id="${id}" type="text" placeholder="${ph}"></div>`;
|
||||
}
|
||||
|
||||
function attachModeListeners() {
|
||||
dynFields.querySelectorAll('#modeToggle button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
dynFields.querySelectorAll('#modeToggle button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentMode = btn.dataset.mode;
|
||||
|
||||
const hint = document.getElementById('modeHint');
|
||||
if (hint) hint.textContent = modeHintText();
|
||||
|
||||
const countWrap = document.getElementById('countWrap');
|
||||
if (countWrap) countWrap.style.display = currentMode === 'cookie' ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toolType.addEventListener('change', renderFields);
|
||||
renderFields();
|
||||
|
||||
// ── Search / filter results ───────────────────────────────────────────────────
|
||||
|
||||
searchInput.addEventListener('input', () => renderCards(currentData, searchInput.value));
|
||||
|
||||
function flatText(obj) {
|
||||
if (obj === null || obj === undefined) return '';
|
||||
if (typeof obj !== 'object') return String(obj);
|
||||
return Object.values(obj).map(flatText).join(' ');
|
||||
}
|
||||
|
||||
const PRIORITY = ['name','username','screen_name','text','full_text','content','title','description','bio','article_text','created_at'];
|
||||
const SKIP = ['profile_image_url','profile_banner_url','entities','extended_entities','urls','media','indices'];
|
||||
const CLAMP = new Set(['text','full_text','content','description','bio','article_text']);
|
||||
|
||||
// ── Media helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function extractMedia(item) {
|
||||
// Cookie mode: item.media = [{type, thumb, url}]
|
||||
if (Array.isArray(item.media) && item.media.length) {
|
||||
const first = item.media[0];
|
||||
if (first && typeof first === 'object' && ('thumb' in first || 'url' in first)) {
|
||||
return item.media;
|
||||
}
|
||||
}
|
||||
// API mode: item.extended_entities.media[] or item.entities.media[]
|
||||
const src = (item.extended_entities && item.extended_entities.media)
|
||||
|| (item.entities && item.entities.media);
|
||||
if (!Array.isArray(src)) return [];
|
||||
return src.map(m => {
|
||||
const mtype = m.type || 'photo';
|
||||
const thumb = m.media_url_https || m.media_url || '';
|
||||
let url = thumb;
|
||||
if (mtype === 'video' || mtype === 'animated_gif') {
|
||||
const variants = (m.video_info && m.video_info.variants) || [];
|
||||
const mp4s = variants.filter(v => v.content_type === 'video/mp4');
|
||||
if (mp4s.length) url = mp4s.reduce((b, v) => (v.bitrate||0) > (b.bitrate||0) ? v : b).url;
|
||||
}
|
||||
return { type: mtype, thumb, url };
|
||||
}).filter(m => m.thumb);
|
||||
}
|
||||
|
||||
function renderMedia(mediaList) {
|
||||
if (!mediaList || !mediaList.length) return '';
|
||||
const html = mediaList.map(m => {
|
||||
const badge = m.type === 'video' ? '▶ VIDEO'
|
||||
: m.type === 'animated_gif' ? '▶ GIF' : '';
|
||||
const inner = m.thumb
|
||||
? `<img src="${esc(m.thumb)}" class="media-thumb" alt="media" loading="lazy">`
|
||||
: '';
|
||||
const badgeHtml = badge ? `<span class="media-badge">${badge}</span>` : '';
|
||||
// photo: opens full-res; video/gif: opens MP4 directly
|
||||
return `<a href="${esc(m.url)}" target="_blank" rel="noopener" class="media-item">${inner}${badgeHtml}</a>`;
|
||||
}).join('');
|
||||
return `<div class="card-media">${html}</div>`;
|
||||
}
|
||||
|
||||
function renderCards(data, query = '') {
|
||||
if (!data) {
|
||||
resultBox.innerHTML = '<div class="empty-state">No results yet.</div>';
|
||||
return;
|
||||
}
|
||||
const items = Array.isArray(data) ? data : [data];
|
||||
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) {
|
||||
resultBox.innerHTML = '<div class="empty-state">No matching results.</div>';
|
||||
return;
|
||||
}
|
||||
resultBox.innerHTML = '<div class="cards-grid">' + filtered.map(buildCard).join('') + '</div>';
|
||||
}
|
||||
|
||||
function buildCard(item) {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return `<div class="card"><div class="card-row"><div class="card-val">${esc(String(item))}</div></div></div>`;
|
||||
}
|
||||
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 (typeof v === 'object') {
|
||||
const s = JSON.stringify(v);
|
||||
display = `<span style="color:var(--muted);font-size:11px">${esc(s.length > 90 ? s.slice(0,90)+'…' : 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 = extractMedia(item);
|
||||
return `<div class="card">${rows}${renderMedia(media)}</div>`;
|
||||
}
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
runBtn.addEventListener('click', async () => {
|
||||
const t = toolType.value;
|
||||
const count = parseInt(val('countInput') || '20', 10);
|
||||
|
||||
const payload = { toolType: t, mode: currentMode, count };
|
||||
|
||||
if (t === 'tweet_search_extractor') payload.searchQuery = val('searchQuery');
|
||||
if (t === 'follower_explorer') payload.targetUsername = val('targetUsername');
|
||||
if (t === 'article_extractor') payload.targetTweetId = val('targetTweetId');
|
||||
if (t === 'community_post_extractor') payload.targetCommunityId = val('targetCommunityId');
|
||||
if (t === 'post_extractor') payload.targetUsername = val('targetUsername');
|
||||
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = 'Running…';
|
||||
statusBadge.textContent = 'running';
|
||||
statusBadge.className = 'badge running';
|
||||
searchInput.style.display = 'none';
|
||||
resultCount.textContent = '';
|
||||
currentData = null;
|
||||
resultBox.innerHTML = '<div class="empty-state">Fetching data…</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.ok) {
|
||||
statusBadge.textContent = 'done';
|
||||
statusBadge.className = 'badge ok';
|
||||
currentData = json.data;
|
||||
const isMany = Array.isArray(json.data) && json.data.length > 1;
|
||||
searchInput.style.display = isMany ? '' : 'none';
|
||||
renderCards(currentData);
|
||||
} else {
|
||||
statusBadge.textContent = 'error';
|
||||
statusBadge.className = 'badge err';
|
||||
resultBox.innerHTML = `<div class="card"><div class="card-row"><div class="card-key">error</div><div class="card-val" style="color:#f87171">${esc(json.error)}</div></div></div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusBadge.textContent = 'error';
|
||||
statusBadge.className = 'badge err';
|
||||
resultBox.innerHTML = `<div class="card"><div class="card-row"><div class="card-key">exception</div><div class="card-val" style="color:#f87171">${esc(String(e))}</div></div></div>`;
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
runBtn.textContent = 'Run';
|
||||
}
|
||||
});
|
||||
|
||||
function val(id) { const el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]); }
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
import configparser
|
||||
import os
|
||||
import requests
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.ini")
|
||||
|
||||
|
||||
class XquikError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str = CONFIG_PATH) -> configparser.ConfigParser:
|
||||
if not os.path.exists(path):
|
||||
raise XquikError(f"Config tidak ditemukan: {path}")
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path)
|
||||
return cfg
|
||||
|
||||
|
||||
class XquikClient:
|
||||
def __init__(self, config: configparser.ConfigParser = None):
|
||||
self.config = config or load_config()
|
||||
self.api_key = self.config.get("xquik", "api_key", fallback="").strip()
|
||||
self.base_url = self.config.get(
|
||||
"xquik", "base_url", fallback="https://xquik.com/api/v1/extractions"
|
||||
).strip()
|
||||
|
||||
if not self.api_key or self.api_key == "xq_YOUR_KEY":
|
||||
raise XquikError("api_key belum diisi di config.ini [xquik]")
|
||||
|
||||
def _post(self, payload: dict) -> dict:
|
||||
headers = {
|
||||
"x-api-key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(self.base_url, headers=headers, json=payload, timeout=30)
|
||||
except requests.RequestException as e:
|
||||
raise XquikError(f"Request gagal: {e}") from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
raise XquikError(f"xquik API error {resp.status_code}: {resp.text}")
|
||||
|
||||
try:
|
||||
return resp.json()
|
||||
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})
|
||||
|
||||
def follower_explorer(self, target_username: str) -> dict:
|
||||
return self._post({"toolType": "follower_explorer", "targetUsername": target_username})
|
||||
|
||||
def article_extractor(self, target_tweet_id: str) -> dict:
|
||||
return self._post({"toolType": "article_extractor", "targetTweetId": target_tweet_id})
|
||||
|
||||
def community_post_extractor(self, target_community_id: str) -> dict:
|
||||
return self._post(
|
||||
{"toolType": "community_post_extractor", "targetCommunityId": target_community_id}
|
||||
)
|
||||
|
||||
def post_extractor(self, target_username: str) -> dict:
|
||||
"""User timeline lewat xquik (pakai kuota API)."""
|
||||
return self._post({"toolType": "post_extractor", "targetUsername": target_username})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(description="xquik.com extraction CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p1 = sub.add_parser("tweet_search")
|
||||
p1.add_argument("query")
|
||||
|
||||
p2 = sub.add_parser("follower_explorer")
|
||||
p2.add_argument("username")
|
||||
|
||||
p3 = sub.add_parser("article_extractor")
|
||||
p3.add_argument("tweet_id")
|
||||
|
||||
p4 = sub.add_parser("community_post_extractor")
|
||||
p4.add_argument("community_id")
|
||||
|
||||
p5 = sub.add_parser("post_extractor")
|
||||
p5.add_argument("username")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = XquikClient()
|
||||
|
||||
try:
|
||||
if args.command == "tweet_search":
|
||||
out = client.tweet_search(args.query)
|
||||
elif args.command == "follower_explorer":
|
||||
out = client.follower_explorer(args.username)
|
||||
elif args.command == "article_extractor":
|
||||
out = client.article_extractor(args.tweet_id)
|
||||
elif args.command == "community_post_extractor":
|
||||
out = client.community_post_extractor(args.community_id)
|
||||
elif args.command == "post_extractor":
|
||||
out = client.post_extractor(args.username)
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
except XquikError as e:
|
||||
print(f"[ERROR] {e}")
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user