diff --git a/Script/README.md b/Script/README.md
index c7500ae..1326185 100644
--- a/Script/README.md
+++ b/Script/README.md
@@ -8,4 +8,17 @@ Happy learning
If any bug, missue and logic please let me know with mention the issue also the script was provided
-Thanks
\ No newline at end of file
+Thanks
+
+# Table Of Content
+
+- [CNN-News-Scrapping-and-Archive](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/CNN-News-Scrapping-and-Archive)
+- [Darkweb-Scrapping](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Darkweb-Scrapping)
+- [Dorking](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Dorking)
+- [Github-Search](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Github-Search)
+- [Linkedin-Script](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Linkedin-Script)
+- [OSM-QUERY-LIST](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/OSM-QUERY-LIST)
+- [Red-Team](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Red-Team)
+- [SENTINEL-SAT-SCRIPT-LIST](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/SENTINEL-SAT-SCRIPT-LIST)
+- [SOCMINT-Twitter](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/SOCMINT-Twitter)
+- [Web-TLD-Enumerate-NS-Check](https://github.com/Jieyab89/OSINT-Cheat-sheet/tree/main/Script/Web-TLD-Enumerate-NS-Check)
\ No newline at end of file
diff --git a/Script/SOCMINT-Twitter/Readme.md b/Script/SOCMINT-Twitter/Readme.md
index 2729079..2bd1b90 100644
--- a/Script/SOCMINT-Twitter/Readme.md
+++ b/Script/SOCMINT-Twitter/Readme.md
@@ -1,6 +1,41 @@
# Jieyab ft Xquik
-
+
+
+## Features
+
+- **Multi-source search** — one query fans out to Cookie, Xquik API,
+ Wayback Machine, and Google CSE in parallel, each result tagged with
+ which source it came from.
+- **10 extraction tools** — tweet search, follower/following explorer, post
+ (timeline) extractor, article extractor, community posts, tweet replies,
+ tweet retweeters, geo-tagged search with a map view, Wayback archive
+ search, and multi-source search.
+- **Infinite scroll + inline nested reply threads** — scroll to load more
+ pages automatically; expand a reply's own replies in place, recursively,
+ the way X's own UI threads a conversation.
+- **Relationship graph** (`/graph`) — a Cytoscape-based node graph. Select a
+ node to expand its replies, retweeters, posts, followers, or following;
+ pivot from any tweet straight to its author (no extra request — the data's
+ already on the tweet); box-select multiple nodes and drag them together;
+ edges are labeled by relationship (*replied by*, *followed by*, *authored
+ by*, ...) and a distinct color flags where two different paths through the
+ graph converge on the same account or tweet.
+- **Archive** (`/archives`) — one-click (or auto-) save of a search's full
+ raw results, including downloaded media, browsable and re-searchable later
+ independent of whether the source is still reachable.
+- **Analytics** (`/analytics`) — sentiment clustering (pro / neutral / con)
+ over a saved archive, plus who posts most, what's driving the most
+ engagement, and word frequency — see [Sentiment Analysis](#sentiment-analysis)
+ below.
+- **Account-age forensics** — every result with a numeric X/Twitter ID gets
+ its account creation date decoded straight from the ID's Snowflake bits
+ (no extra API call), flagged New / Recent / Established.
+- **Security-conscious by default** — strict CSP with per-request nonces,
+ hardened cookies, a whitelisted SSRF-safe video proxy, per-source request
+ throttling to protect the logged-in account from rate limits, and
+ URL-scheme validation everywhere a scraped link is rendered as an image or
+ embedded in CSS.
# Sett up
@@ -31,18 +66,109 @@ Check the result in the table
3. Wayback Machine (Cdx API)
4. Goole CSE API (free quota 100 per day u can increase u limit with buy the service)
+## Usage
+
+### Search (`/`)
+
+Pick a tool from the dropdown, fill in the field it asks for (query,
+username, tweet ID, ...), and hit **Run**. Cookie-only tools (marked
+`[Cookie]`) always use your logged-in session; everything else lets you
+toggle between **Cookie** (your session, no xquik quota used) and **xquik
+API** (uses your API key's quota) mode.
+
+- Scroll down to auto-load more pages on any tool that supports pagination.
+- Any card whose post has replies of its own gets an **Expand N replies**
+ button — click it to thread the conversation inline, as deep as it
+ actually goes.
+- Toggle **Auto Archive** before running a search to save results as you go
+ (including every scrolled-in page) — see [Archives](#archives-archives).
+- **Geo Post Extractor** switches the results view to a map, geocoding each
+ author's profile location.
+- The `↓ JSON` button downloads exactly what's on screen as raw JSON.
+
+### Graph (`/graph`)
+
+Same 10 tools, rendered as a node graph instead of a card list.
+
+- Click a node to inspect its full raw data in the side panel.
+- **Expand Replies / Retweets** (tweet or reply nodes), **Expand Posts /
+ Followers / Following** (user or retweeter nodes), and **View Author
+ Profile** (tweet/reply nodes — pulls the author out as their own node,
+ for free) all attach new nodes with a labeled edge showing the
+ relationship.
+- **Shift/Alt/Ctrl + drag** on empty canvas to box-select multiple nodes;
+ drag any one of them to move the whole selection together. Plain drag
+ still pans, scroll still zooms.
+- A **yellow edge** means two different paths through the graph converged
+ on the same node — worth a second look.
+- **Archive All** / **Dump JSON** export everything currently on canvas.
+- Click the **?** button (bottom-left) for the full legend.
+
+### Archives (`/archives`)
+
+Browse everything saved from Search or Graph. Pick an archive from the
+sidebar to see its full raw results and any downloaded media. Archives
+persist independent of whether the original source is still reachable —
+useful for content that gets deleted or a session that expires.
+
+### Analytics (`/analytics`)
+
+Pick a saved archive to run sentiment clustering and the surrounding
+aggregates over it:
+
+- **Sentiment clustering** — every item with text gets classified **pro** /
+ **neutral** / **con**, shown as a diverging bar plus per-category tiles
+ you can click to filter the item list below.
+- **Most active accounts** — who shows up most often in that archive.
+- **Most engagement** — which items drove the most reply+retweet+like
+ activity.
+- **Word frequency** — a word cloud sized by how often each word appears.
+- **Item browser** — every scored item, filterable by sentiment and
+ searchable by text/author, with the model's confidence (or, in lexicon
+ fallback mode, the exact matched words) shown per item — a classification
+ is always inspectable, never a black box.
+
+## Sentiment Analysis
+
+Two backends, tried in this order (see `sentiment.py`):
+
+1. **ML (preferred)** — [`cardiffnlp/twitter-xlm-roberta-base-sentiment`](https://huggingface.co/cardiffnlp/twitter-xlm-roberta-base-sentiment),
+ an XLM-RoBERTa model fine-tuned for tweet sentiment across 8 languages
+ (Arabic, English, French, German, Hindi, Italian, Portuguese, Spanish).
+ Its underlying pretraining covers roughly 100 languages, so it degrades
+ gracefully rather than failing outright on a language outside that
+ fine-tuned set — this is what makes the tool usable for a global
+ audience, not just Indonesian speakers. Requires `torch` + `transformers`
+ (see `requirements.txt`) and downloads ~1.1GB of model weights from
+ Hugging Face the first time it runs.
+2. **Lexicon fallback** — a hand-built Indonesian positive/negative word
+ list with negation handling (e.g. *"tidak bagus"* flips from positive to
+ negative). Used automatically whenever `torch`/`transformers` aren't
+ installed, so a lightweight install still has a working — if
+ Indonesian-only — sentiment feature instead of a hard failure.
+
+The `/api/analytics/` response always reports which backend
+(`"ml"` or `"lexicon"`) produced its results, and the Analytics page's
+banner reflects it. Neither backend is a ground-truth classifier — short
+text, sarcasm, and irony degrade accuracy either way. Treat results as a
+starting point for investigation, not a verdict.
+
## Update Note
-1. Update infinity scroll and load new data for twitter reply and retweets
+1. Update infinity scroll and load new data for each search also in graph
2. Update data corelation
3. Fix business logic flow
4. Monitoring (Soon)
5. MCP (Soon)
6. Add more parameter for enrichment
-7. Add no rate limit (throttle)
+7. Add no rate limit (throttle)
8. Add Google CSE data source
+9. Expand data user profile post, follower and following, reply post, retweet post in graph —
+10. Add sentiment analysis for clustering data, pro, neutral, con based on archive data and dump data
+11. Add more data source and other parameter (soon) still research
+12. Add more detail data source for the context
-## Setup
+## Settup
```bash
pip install -r requirements.txt
@@ -50,6 +176,83 @@ pip install -r requirements.txt
Edit `config.ini.example` to config.ini
+## Installing the ML sentiment model (optional, recommended)
+
+The Analytics page's sentiment scoring works two ways — see
+[Sentiment Analysis](#sentiment-analysis) above. Skipping everything on this
+page is completely fine: the app still runs and Analytics still works, just
+using the Indonesian-only lexicon instead of the multilingual model. This
+section is only for turning on the better (multilingual) one. **You don't
+need to know Python or be a developer to follow this — it's copy/paste.**
+
+**Why a "virtual environment" (venv)?** It's just a private, throwaway
+folder for this project's Python packages, kept separate from anything else
+Python-related already on your computer. The ML packages (`torch`,
+`transformers`) are large and can conflict with other unrelated tools if
+installed system-wide — a venv avoids that entirely, and if anything ever
+goes wrong, you just delete the `.venv` folder and start over, nothing else
+on your machine is touched.
+
+**1. Open a terminal in this project's folder** (the same folder as
+`app.py`).
+
+**2. Create the venv** (only needs to be done once):
+
+```bash
+python3 -m venv .venv
+```
+
+**3. Activate it** (needs to be done every time you open a new terminal to
+work on this project):
+
+- macOS / Linux:
+ ```bash
+ source .venv/bin/activate
+ ```
+- Windows (Command Prompt):
+ ```bat
+ .venv\Scripts\activate.bat
+ ```
+- Windows (PowerShell):
+ ```powershell
+ .venv\Scripts\Activate.ps1
+ ```
+
+Your terminal prompt should now start with `(.venv)` — that means it's
+active and every `pip install` from here on stays inside this project's
+private folder.
+
+**4. Install everything** (the base app + the ML packages), in this order:
+
+```bash
+pip install --upgrade pip
+pip install flask requests twifork
+pip install --index-url https://download.pytorch.org/whl/cpu torch
+pip install transformers sentencepiece protobuf
+```
+
+The third line is deliberately its own command — installing `torch` the
+plain way can pull down a multi-gigabyte GPU-enabled build depending on your
+system, when this app only ever needs the much smaller CPU version. Using
+that exact command is what keeps the download small.
+
+**5. Run the app as usual** (make sure `.venv` is still active — you'll see
+`(.venv)` in your prompt):
+
+```bash
+python app.py
+```
+
+The **first time** you use the Analytics page after this, it will download
+the sentiment model itself (~1.1GB) from Hugging Face automatically — this
+needs an internet connection and can take a few minutes depending on your
+connection, but only happens once. After that, it's cached on your computer
+and loads instantly.
+
+**Next time you come back to work on this project**, you only need step 3
+again (activate) before running the app — steps 1, 2, and 4 are one-time
+setup.
+
## Run Local Web Server
```bash
@@ -66,33 +269,41 @@ Xquik Dashboard
Dasboard Home
-
+
Archive
-
+
-
+
Graph
-
+
Vidio
-
+
Dir Output
-# Help
+Sentiment Analysis
+
+
+
+
+
+
+
+# Help
About SnowflakeID -> Twitter userid : https://en.wikipedia.org/wiki/Snowflake_ID
About paramater was provided in data and dump with json file type
-
+
Xquik API DOC
diff --git a/Script/SOCMINT-Twitter/app.py b/Script/SOCMINT-Twitter/app.py
index 3e03083..d94fe66 100644
--- a/Script/SOCMINT-Twitter/app.py
+++ b/Script/SOCMINT-Twitter/app.py
@@ -10,6 +10,7 @@ import requests as _req
from flask import Flask, g, jsonify, render_template, request, Response, stream_with_context, send_from_directory
import archive as _archive
+import sentiment as _sentiment
from xquik_client import XquikClient, XquikError, load_config
from wayback_client import wayback_search, WaybackError
from google_cse_client import google_cse_search, GoogleCSEError
@@ -17,6 +18,7 @@ from id_forensics import enrich_account_age
from cookie_client import (
cookie_tweet_search,
cookie_follower_explorer,
+ cookie_following_explorer,
cookie_post_extractor,
cookie_article_extractor,
cookie_community_post_extractor,
@@ -31,6 +33,7 @@ config = load_config()
MAX_CONCURRENT_REQUESTS = 3 # parallel execution slots
ACQUIRE_TIMEOUT = 15 # seconds to wait before returning 429
+MAX_COUNT = 2000 # upper bound on a single page's requested item count
_sem = threading.Semaphore(MAX_CONCURRENT_REQUESTS)
@@ -47,21 +50,33 @@ _throttle_lock = threading.Lock()
_last_call_at: dict = {"cookie": 0.0, "wayback": 0.0}
_COOKIE_TOOLS = {
- "tweet_search_extractor", "follower_explorer", "post_extractor",
+ "tweet_search_extractor", "follower_explorer", "following_explorer", "post_extractor",
"community_post_extractor", "tweet_replies_extractor",
"tweet_retweeters_extractor", "geo_post_extractor",
}
-def _check_throttle(source: str):
- """None if the call may proceed (and starts the next cooldown window);
- otherwise the number of seconds still left to wait."""
+def _check_throttle(sources):
+ """None if the call may proceed (and starts the next cooldown window for
+ every source in `sources`); otherwise the number of seconds still left to
+ wait. Checking is atomic across all requested sources — if any one of
+ them is still cooling down, none of the clocks are touched, so a
+ rejected multi-source call (e.g. multi_source_search, which hits both
+ the cookie and wayback clocks) never partially starts a window for the
+ sources that *did* have room."""
+ if isinstance(sources, str):
+ sources = (sources,)
now = time.monotonic()
with _throttle_lock:
- elapsed = now - _last_call_at[source]
- if elapsed < _THROTTLE_SECONDS:
- return round(_THROTTLE_SECONDS - elapsed, 1)
- _last_call_at[source] = now
+ wait = 0.0
+ for source in sources:
+ elapsed = now - _last_call_at[source]
+ if elapsed < _THROTTLE_SECONDS:
+ wait = max(wait, _THROTTLE_SECONDS - elapsed)
+ if wait:
+ return round(wait, 1)
+ for source in sources:
+ _last_call_at[source] = now
return None
# ── Cookie & session security ─────────────────────────────────────────────────
@@ -267,30 +282,61 @@ def _filter_by_date(items: list, from_date: str, to_date: str) -> list:
return kept
-def _multi_source_search(query: str, count: int, from_date: str = "", to_date: str = "") -> list:
- # Pagination isn't wired up for multi-source search yet (cookie + wayback
- # only, per current scope) — grab just the items, discard the cursor.
+def _multi_source_search(
+ query: str, count: int, from_date: str = "", to_date: str = "", cursor: str | None = None,
+) -> tuple[list, str | None]:
+ """Fans out across every source in parallel. cursor (if given) is an
+ opaque JSON object of {source: source_cursor} built from a previous
+ call's returned cursor — each key present in it is a source that still
+ had more to give, so only those get re-queried. xquik has no pagination
+ at all (no cursor concept), so it's only ever queried on the first page
+ (cursor=None); every load-more page after that is cookie/wayback/cse only.
+ A cursor value that doesn't parse as a JSON object is treated as "no
+ cursor" (first page) rather than raising — same tolerant-of-garbage-input
+ posture as the rest of this file's client-supplied-field handling."""
twitter_query = _apply_date_operators(query, from_date, to_date)
- jobs = {
- "cookie": lambda: cookie_tweet_search(twitter_query, count=count, config=config)[0],
- "xquik": lambda: XquikClient(config).tweet_search(twitter_query),
- "wayback": lambda: wayback_search(query, count=count, from_date=from_date, to_date=to_date)[0],
+
+ try:
+ incoming = json.loads(cursor) if cursor else {}
+ if not isinstance(incoming, dict):
+ incoming = {}
+ except (TypeError, ValueError):
+ incoming = {}
+ first_page = not incoming
+
+ jobs = {}
+ if first_page or "cookie" in incoming:
+ c = incoming.get("cookie")
+ jobs["cookie"] = lambda c=c: cookie_tweet_search(twitter_query, count=count, config=config, cursor=c)
+ if first_page:
+ jobs["xquik"] = lambda: (XquikClient(config).tweet_search(twitter_query), None)
+ if first_page or "wayback" in incoming:
+ c = incoming.get("wayback")
+ jobs["wayback"] = lambda c=c: wayback_search(query, count=count, from_date=from_date, to_date=to_date, cursor=c)
+ if first_page or "cse" in incoming:
+ c = incoming.get("cse")
# Google has no since:/until: query syntax like Twitter/Wayback do, so
# this lane runs unbounded by date — _filter_by_date below keeps
# results whose own timestamp it can't verify rather than dropping them.
- "cse": lambda: google_cse_search(query, count=count, config=config)[0],
- }
+ jobs["cse"] = lambda c=c: google_cse_search(query, count=count, config=config, cursor=c)
+
+ results = []
+ next_cursor_parts = {}
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
futures = {key: pool.submit(fn) for key, fn in jobs.items()}
- results = []
for key in ("cookie", "xquik", "wayback", "cse"): # deterministic display order
+ if key not in futures:
+ continue
try:
- data = futures[key].result()
+ data, next_c = futures[key].result()
except Exception:
continue # a source failing (missing creds, network, ...) shouldn't sink the others
results.extend(_tag_source(data, SOURCE_LABELS[key]))
+ if next_c:
+ next_cursor_parts[key] = next_c
- return _filter_by_date(results, from_date, to_date)
+ next_cursor = json.dumps(next_cursor_parts) if next_cursor_parts else None
+ return _filter_by_date(results, from_date, to_date), next_cursor
# Whitelist: only proxy Twitter's video CDN to prevent SSRF
@@ -325,23 +371,27 @@ 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))
+ count = max(1, min(int(body.get("count", 20)), MAX_COUNT))
cursor = body.get("cursor") or None # opaque page token from a previous response's nextCursor
# Cookie/Wayback calls are throttled to one per 5s per source — checked
# up front, before taking a concurrency slot, so a request that's about
- # to be rejected doesn't waste one.
- throttle_source = None
+ # to be rejected doesn't waste one. multi_source_search fans out to both
+ # cookie and wayback internally, so it's checked (and, once it proceeds,
+ # starts the cooldown) against both clocks at once.
+ throttle_sources = []
if mode == "cookie" and tool_type in _COOKIE_TOOLS:
- throttle_source = "cookie"
+ throttle_sources = ["cookie"]
elif tool_type == "wayback_archive_search":
- throttle_source = "wayback"
- if throttle_source:
- wait = _check_throttle(throttle_source)
+ throttle_sources = ["wayback"]
+ elif tool_type == "multi_source_search":
+ throttle_sources = ["cookie", "wayback"]
+ if throttle_sources:
+ wait = _check_throttle(throttle_sources)
if wait is not None:
return jsonify({
"ok": False,
- "error": f"Please wait {wait}s before the next {throttle_source} request — this protects the account from rate limiting.",
+ "error": f"Please wait {wait}s before the next {'/'.join(throttle_sources)} request — this protects the account from rate limiting.",
"retryAfter": wait,
}), 429
@@ -368,6 +418,12 @@ def run_tool():
else:
data = XquikClient(config).follower_explorer(username)
+ elif tool_type == "following_explorer":
+ username = body.get("targetUsername", "")
+ if mode != "cookie":
+ return jsonify({"ok": False, "error": "following_explorer requires cookie mode"}), 400
+ data, next_cursor = cookie_following_explorer(username, count=count, config=config, cursor=cursor)
+
elif tool_type == "article_extractor":
tweet_id = body.get("targetTweetId", "")
if mode == "cookie":
@@ -423,7 +479,7 @@ def run_tool():
for label, val in (("dateFrom", from_date), ("dateTo", to_date)):
if val and not _valid_date8(val):
return jsonify({"ok": False, "error": f"{label} must be an 8-digit date (YYYYMMDD)"}), 400
- data = _multi_source_search(query, count=count, from_date=from_date, to_date=to_date)
+ data, next_cursor = _multi_source_search(query, count=count, from_date=from_date, to_date=to_date, cursor=cursor)
else:
return jsonify({"ok": False, "error": f"Unknown toolType: {tool_type}"}), 400
@@ -431,7 +487,7 @@ def run_tool():
# Single choke point: every tool's output passes through here, so the
# account-age label, fetch timestamp, and tweet_url all show up
# everywhere downstream for free — cards, graph nodes, JSON dump, and
- # archives (once the fields are whitelisted in archive.py's _pick_fields).
+ # archives (which now store the item's full raw shape verbatim).
data = enrich_account_age(data)
data = _stamp_fetched_at(data)
data = _stamp_tweet_url(data)
@@ -519,6 +575,22 @@ def archive_list():
return jsonify({"ok": True, "archives": _archive.list_all()})
+# ── Analytics (sentiment / clustering) ──────────────────────────────────────
+
+@app.route("/analytics")
+def analytics_viewer():
+ return render_template("analytics.html")
+
+
+@app.route("/api/analytics/")
+def analytics_run(archive_id):
+ results_file = _archive.ARCHIVE_ROOT / archive_id / "results.json"
+ if not results_file.exists():
+ return jsonify({"ok": False, "error": "Archive not found"}), 404
+ items = json.loads(results_file.read_text())
+ return jsonify({"ok": True, **_sentiment.analyze(items)})
+
+
if __name__ == "__main__":
host = config.get("server", "host", fallback="127.0.0.1")
port = config.getint("server", "port", fallback=5000)
diff --git a/Script/SOCMINT-Twitter/archive.py b/Script/SOCMINT-Twitter/archive.py
index 27d4788..0a0178f 100644
--- a/Script/SOCMINT-Twitter/archive.py
+++ b/Script/SOCMINT-Twitter/archive.py
@@ -102,24 +102,6 @@ def _download_file(url: str, dest: Path) -> bool:
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",
- "verified", "is_blue_verified",
- "description", "followers_count", "following_count", "tweet_count",
- "lat", "lon", "place",
- "retweeted_text", "retweeted_by_user", "retweeted_by_name",
- "retweeted_by_bio", "retweeted_at", "retweeted_tweet_id",
- "iso_date", "original", "statuscode", "mimetype", "length",
- "archive_url", "post_title", "post_text", "preview_image",
- "result_url", "display_link", "fetched_at",
- "source", "account_created", "account_age", "account_age_flag",
- "account_age_precision"]
- 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:
@@ -139,7 +121,10 @@ def _run(archive_id: str, tool_type: str, data, query_info: dict) -> None:
enriched.append(item)
continue
- record = _pick_fields(item)
+ # Archive the item's full raw shape (every field the source API gave
+ # us), not a whitelisted subset — downstream sentiment analysis needs
+ # the raw record, not just the fields the card UI happens to display.
+ record = dict(item)
record["tweet_url"] = item.get("tweet_url") or build_tweet_url(item)
local_media = []
diff --git a/Script/SOCMINT-Twitter/cookie_client.py b/Script/SOCMINT-Twitter/cookie_client.py
index f2fcef8..b40a0cd 100644
--- a/Script/SOCMINT-Twitter/cookie_client.py
+++ b/Script/SOCMINT-Twitter/cookie_client.py
@@ -71,12 +71,78 @@ def _id_str(val) -> str | None:
return str(val) if val is not None else None
+def _full_text(t: object) -> str | None:
+ """twikit's `.text` is Twitter's own legacy `full_text` field — despite the
+ name, X still truncates *that* mid-sentence into a t.co link for anything
+ past the classic length limit (long-form "Note" tweets, e.g. Premium/Blue
+ posts). twikit's `.full_text` PROPERTY (a different thing from the legacy
+ field of the same name) checks the tweet's note_tweet payload first and
+ returns the real complete text when one exists, falling back to `.text`
+ itself otherwise — so it's always at least as complete, strictly more so
+ for long tweets. Swallows a malformed note_tweet shape rather than letting
+ one tweet's data take down the whole batch."""
+ try:
+ return getattr(t, "full_text", None)
+ except Exception:
+ return None
+
+
+def _hashtags(t: object) -> list | None:
+ """Same note_tweet-aware source as _full_text — a long-form tweet's
+ hashtags live in the note_tweet entity set, not the legacy entities twikit
+ falls back to otherwise."""
+ try:
+ tags = getattr(t, "hashtags", None)
+ return tags or None
+ except Exception:
+ return None
+
+
+def _leading_reply_mentions(t: object) -> list | None:
+ """Tapping "Reply" on X auto-prefixes the compose box with every account
+ the reply-chain already has tagged — not just the tweet being replied to
+ — and that prefix is genuinely stored as the literal start of the
+ reply's own full_text. X's own web/app UI never shows it inline though:
+ it reads `display_text_range` (the slice of full_text actually meant to
+ be *shown*) and renders anything before that start index as a separate
+ "Replying to @x @y" line instead. Skipping this meant our raw `text`
+ looked like the replier had typed those @mentions themselves — e.g. a
+ reply that only ever says "Proyek kepentingan, bukan untuk rakyat..."
+ displayed as if it opened with "@regar_op0sisi @prabowo ...", which is
+ exactly what looked wrong compared to the tweet on x.com. This only
+ covers the un-extended legacy text/entities — a long-form Note tweet's
+ entity indices belong to its own separate note_tweet string, which this
+ intentionally does not touch rather than risk slicing the wrong string.
+ Reads twikit's private `_legacy`/`_note_tweet_results` (no public
+ equivalent exists) — same trade-off already made for `_get_more_replies`
+ elsewhere in this file. Returns None rather than raising on any
+ unexpected shape, since this is purely a display aid, never the record
+ of truth `text` already is."""
+ try:
+ if t._note_tweet_results:
+ return None
+ legacy = t._legacy
+ start = (legacy.get("display_text_range") or [0])[0]
+ if not start:
+ return None
+ names = []
+ for m in (legacy.get("entities") or {}).get("user_mentions", []) or []:
+ idx = m.get("indices") or [None, None]
+ if idx[0] is not None and idx[1] is not None and idx[1] <= start:
+ sn = m.get("screen_name")
+ if sn:
+ names.append(sn)
+ return names or None
+ except Exception:
+ return None
+
+
def _tweet_to_dict(t: object) -> dict:
user_obj = getattr(t, "user", None)
d = {
"id": _id_str(getattr(t, "id", None)),
"created_at": getattr(t, "created_at", None),
- "text": getattr(t, "text", None),
+ "text": _full_text(t) or getattr(t, "text", 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,
@@ -85,6 +151,11 @@ def _tweet_to_dict(t: object) -> dict:
"name": getattr(user_obj, "name", None) if user_obj else None,
"verified": getattr(user_obj, "verified", None) if user_obj else None,
"is_blue_verified": getattr(user_obj, "is_blue_verified", None) if user_obj else None,
+ # Author's avatar/cover/bio — twikit's embedded user object on a tweet
+ # already carries these, no extra lookup needed.
+ "user_avatar": getattr(user_obj, "profile_image_url", None) if user_obj else None,
+ "user_banner": getattr(user_obj, "profile_banner_url", None) if user_obj else None,
+ "user_bio": getattr(user_obj, "description", 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),
@@ -94,6 +165,12 @@ def _tweet_to_dict(t: object) -> dict:
media = _extract_media(t)
if media:
d["media"] = media
+ tags = _hashtags(t)
+ if tags:
+ d["hashtags"] = tags
+ mentions = _leading_reply_mentions(t)
+ if mentions:
+ d["reply_to_mentions"] = mentions
return d
@@ -103,6 +180,8 @@ def _user_to_dict(u: object) -> dict:
"name": getattr(u, "name", None),
"screen_name": getattr(u, "screen_name", None),
"description": getattr(u, "description", None),
+ "avatar": getattr(u, "profile_image_url", None),
+ "banner": getattr(u, "profile_banner_url", None),
"followers_count": getattr(u, "followers_count", None),
"following_count": getattr(u, "following_count", None),
"tweet_count": getattr(u, "statuses_count", None),
@@ -145,6 +224,16 @@ async def _follower_explorer_async(username: str, auth_token: str, ct0: str, cou
return [_user_to_dict(u) for u in followers], _next_cursor(followers)
+async def _following_explorer_async(username: str, auth_token: str, ct0: str, count: int, cursor: str | None) -> tuple[list, str | None]:
+ """Who `username` follows — the other half of follower_explorer. twikit
+ exposes this as Client.get_user_following, same shape/cursor contract as
+ get_user_followers, so this mirrors _follower_explorer_async exactly."""
+ client = await _make_client(auth_token, ct0)
+ user = await _resolve_user(client, username)
+ following = await client.get_user_following(str(user.id), count=count, cursor=cursor)
+ return [_user_to_dict(u) for u in following], _next_cursor(following)
+
+
async def _post_extractor_async(username: str, auth_token: str, ct0: str, count: int, cursor: str | None) -> tuple[list, str | None]:
client = await _make_client(auth_token, ct0)
user = await _resolve_user(client, username)
@@ -265,6 +354,12 @@ def cookie_follower_explorer(username: str, count: int = 20, config: configparse
return asyncio.run(_follower_explorer_async(username, auth, ct0, count, cursor))
+def cookie_following_explorer(username: str, count: int = 20, config: configparser.ConfigParser = None, cursor: str | None = None) -> tuple[list, str | None]:
+ cfg = config or load_config()
+ auth, ct0 = _get_creds(cfg)
+ return asyncio.run(_following_explorer_async(username, auth, ct0, count, cursor))
+
+
def cookie_post_extractor(username: str, count: int = 20, config: configparser.ConfigParser = None, cursor: str | None = None) -> tuple[list, str | None]:
cfg = config or load_config()
auth, ct0 = _get_creds(cfg)
diff --git a/Script/SOCMINT-Twitter/google_cse_client.py b/Script/SOCMINT-Twitter/google_cse_client.py
index bcd9922..be51c17 100644
--- a/Script/SOCMINT-Twitter/google_cse_client.py
+++ b/Script/SOCMINT-Twitter/google_cse_client.py
@@ -46,6 +46,14 @@ _META_TAG_RE = re.compile(r"]*>", re.IGNORECASE)
_ATTR_RE = re.compile(r'''([\w:-]+)\s*=\s*"([^"]*)"|([\w:-]+)\s*=\s*'([^']*)\'''')
_TITLE_TAG_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL)
_TWEET_ID_RE = re.compile(r"/status/(\d+)")
+_X_HOST_RE = re.compile(r"^https?://(?:www\.)?(?:x|twitter)\.com(?:/|$)", re.IGNORECASE)
+_PROFILE_RE = re.compile(r"^https?://(?:www\.)?(?:x|twitter)\.com/([^/?#]+)/?(?:\?.*)?$", re.IGNORECASE)
+# Path segments that look like a profile URL shape but aren't a person/org
+# account — X's own site-nav pages live at this same depth.
+_NON_PROFILE_PATHS = {
+ "home", "explore", "notifications", "messages", "i", "search", "settings",
+ "compose", "login", "logout", "signup", "tos", "privacy", "about",
+}
def _tweet_created_at(url: str) -> str | None:
@@ -64,6 +72,26 @@ def _tweet_created_at(url: str) -> str | None:
return dt.strftime("%a %b %d %H:%M:%S +0000 %Y")
+def _classify_url(url: str) -> str:
+ """A search result linking to x.com/someone with no other context reads
+ as "a Twitter profile" whether it's actually a specific tweet, a bare
+ profile page, or some other X page entirely — this is a real user report:
+ a result was shown for a keyword match with no way to tell that it was
+ (or wasn't) an actual tweet permalink. Purely a label derived from the
+ URL's own shape; never touches the URL/title/snippet themselves.
+ Returns 'tweet' | 'profile' | 'twitter_other' | 'other'."""
+ if not url:
+ return "other"
+ if _TWEET_ID_RE.search(url):
+ return "tweet"
+ if not _X_HOST_RE.match(url):
+ return "other"
+ m = _PROFILE_RE.match(url)
+ if m and m.group(1).lower() not in _NON_PROFILE_PATHS:
+ return "profile"
+ return "twitter_other"
+
+
class GoogleCSEError(Exception):
pass
@@ -95,13 +123,27 @@ def _row_to_record(item: dict) -> dict:
record = {}
title = item.get("title")
if title:
- record["post_title"] = html.unescape(title).strip()
+ # Kept as serp_title even after _enrich_records below potentially
+ # overwrites post_title with a live re-fetch — X serves bots a
+ # generic/gated page for most URLs, so a live fetch of an X link
+ # often returns less specific content than Google's own SERP/cache
+ # already had. Without this, that overwrite silently threw away the
+ # more useful value with no way to get it back.
+ record["serp_title"] = html.unescape(title).strip()
+ record["post_title"] = record["serp_title"]
snippet = item.get("snippet")
if snippet:
- record["post_text"] = html.unescape(snippet).strip()
+ # Google truncates this itself (ends in "…" mid-sentence) — that's
+ # the SERP snippet as Google's own API hands it back, not something
+ # this scraper cuts short. Named `description` (not `serp_snippet`)
+ # to read clearly as "what this page is about" next to post_text
+ # (the live-fetched og:description, which may or may not agree).
+ record["description"] = html.unescape(snippet).strip()
+ record["post_text"] = record["description"]
link = item.get("link")
if link and _SAFE_URL_RE.match(link):
- record["result_url"] = link
+ record["result_url"] = link
+ record["content_type"] = _classify_url(link)
created_at = _tweet_created_at(link)
if created_at:
record["created_at"] = created_at # when the post itself was actually made
@@ -173,8 +215,13 @@ def _fetch_live_meta(url: str) -> dict:
def _enrich_records(records: list[dict]) -> None:
- """Mutates each record in place. Runs in parallel — one slow/dead site
- shouldn't hold up the rest of the result set."""
+ """Mutates each record in place — post_title/post_text become "best known
+ value," preferring a fresh live fetch over Google's SERP snapshot when
+ one succeeds. serp_title/description (set in _row_to_record, before this
+ runs) are never touched here, so Google's original values always survive
+ even when this overwrites post_title/post_text with something less
+ useful (X routinely serves bots a generic/gated page). Runs in parallel —
+ one slow/dead site shouldn't hold up the rest of the result set."""
candidates = [r for r in records if r.get("result_url")]
if not candidates:
return
diff --git a/Script/SOCMINT-Twitter/requirements.txt b/Script/SOCMINT-Twitter/requirements.txt
index 0045a81..bd5b8ea 100644
--- a/Script/SOCMINT-Twitter/requirements.txt
+++ b/Script/SOCMINT-Twitter/requirements.txt
@@ -1,3 +1,7 @@
flask
requests
-twifork
\ No newline at end of file
+twifork
+torch
+transformers
+sentencepiece
+protobuf
\ No newline at end of file
diff --git a/Script/SOCMINT-Twitter/sentiment.py b/Script/SOCMINT-Twitter/sentiment.py
new file mode 100644
index 0000000..0c03054
--- /dev/null
+++ b/Script/SOCMINT-Twitter/sentiment.py
@@ -0,0 +1,368 @@
+"""Sentiment clustering (pro / neutral / con) for archived X/Twitter data,
+plus the supporting "who's loudest, what's trending, what words dominate"
+aggregates — the same shape of dashboard Drone Emprit-style tools give a
+dataset.
+
+Two scoring backends, tried in this order:
+
+1. ML (preferred) — cardiffnlp/twitter-xlm-roberta-base-sentiment, an
+ XLM-RoBERTa model fine-tuned for tweet sentiment across 8 languages
+ (ar/en/fr/de/hi/it/pt/es). Its base pretraining covers ~100 languages, so
+ it degrades gracefully rather than failing outright on a language outside
+ that fine-tuning set — this is what makes the tool usable for an
+ open-source audience that isn't Indonesian-only. Needs torch +
+ transformers (see requirements.txt) and ~1.1GB of model weights
+ downloaded from Hugging Face on first use.
+2. Lexicon fallback — a hand-built Indonesian positive/negative word list
+ with basic negation handling ("tidak bagus" flips "bagus" from positive
+ to negative). Used automatically whenever torch/transformers aren't
+ installed, so a lightweight install (just requirements.txt's base deps)
+ still has a working — if Indonesian-only — sentiment feature rather than
+ a hard failure. Every scored item exposes exactly which backend produced
+ it (`method` on the analyze() payload), so a classification's provenance
+ is never ambiguous, and the lexicon path additionally exposes the exact
+ words that drove its score for full transparency.
+
+Neither backend is a ground-truth classifier — short text, sarcasm, and
+irony all degrade accuracy regardless of approach. Treat results as a
+starting point for investigation, not a verdict.
+"""
+
+import re
+import threading
+from collections import Counter
+
+# Local fallback
+
+# ── Sentiment lexicon ───────────────────────────────────────────────────────
+# Indonesian words skew political/social-discourse (matches the kind of
+# content this tool actually pulls — keyword searches on public affairs,
+# government programs, public figures) as well as general register.
+
+# Arr data words
+# Need to feedback and research to sett the all parameter for each words
+# Need to help netizen Indo, with slang and words
+
+POSITIVE_WORDS = {
+ "bagus", "baik", "hebat", "keren", "mantap", "mantul", "top", "terbaik",
+ "sukses", "berhasil", "tepat", "benar", "setuju", "dukung", "dukungan",
+ "mendukung", "apresiasi", "mengapresiasi", "bangga", "membanggakan",
+ "senang", "gembira", "puas", "memuaskan", "bersyukur", "syukur",
+ "alhamdulillah", "semoga", "maju", "kemajuan", "bijak", "bijaksana",
+ "cerdas", "pintar", "amanah", "jujur", "kejujuran", "adil", "keadilan",
+ "peduli", "kepedulian", "bermanfaat", "manfaat", "membantu", "bantuan",
+ "solusi", "prestasi", "berprestasi", "unggul", "keunggulan", "luar biasa",
+ "joss", "jos", "gas terus", "lanjutkan", "sip", "oke", "aman", "tenang",
+ "damai", "sejahtera", "kesejahteraan", "makmur", "kemakmuran", "indah",
+ "cantik", "tampan", "ramah", "sopan", "santun", "hormat", "menghormati",
+ "salut", "kagum", "mengagumkan", "respect", "terharu", "terinspirasi",
+ "inspiratif", "positif", "optimis", "optimisme", "harapan", "berharap",
+ "cinta", "mencintai", "sayang", "suka", "menyukai", "rindu", "kangen",
+ "gemas", "lucu", "menghibur", "menyenangkan", "menginspirasi", "tegas",
+ "berani", "keberanian", "kuat", "gigih", "semangat", "bersemangat",
+ "sukacita", "kompeten", "profesional", "berkualitas", "kualitas",
+ "elegan", "canggih", "inovatif", "inovasi", "kreatif", "efisien",
+ "efektif", "transparan", "transparansi", "akuntabel", "akuntabilitas",
+ "merakyat", "membela rakyat", "pro rakyat", "berpihak pada rakyat",
+ "terpuji", "membanggakan", "gemilang", "cemerlang", "berkah", "istimewa",
+}
+
+NEGATIVE_WORDS = {
+ "buruk", "keburukan", "jelek", "gagal", "kegagalan", "bodoh", "tolol",
+ "goblok", "bego", "dungu", "idiot", "korupsi", "korup", "koruptor",
+ "bohong", "kebohongan", "hoax", "menipu", "penipu", "penipuan", "tipu",
+ "curang", "kecurangan", "culas", "zalim", "menzalimi", "kejam",
+ "kekejaman", "jahat", "kejahatan", "rusak", "merusak", "hancur",
+ "menghancurkan", "kacau", "mengacaukan", "parah", "memalukan",
+ "memuakkan", "bejat", "biadab", "tolak", "menolak", "penolakan",
+ "kecewa", "mengecewakan", "kekecewaan", "marah", "kemarahan", "murka",
+ "benci", "membenci", "kebencian", "muak", "jijik", "menjijikkan",
+ "sampah", "anjing", "bangsat", "bajingan", "kampret", "sialan",
+ "kacung", "boneka", "munafik", "kemunafikan", "pengkhianat",
+ "mengkhianati", "khianat", "pengkhianatan", "penjajah", "menjajah",
+ "licik", "licin", "serakah", "keserakahan", "tamak", "otoriter",
+ "diktator", "fasis", "salah", "kesalahan", "blunder", "konyol",
+ "ngawur", "ngaco", "absurd", "aneh", "ironis", "ironi", "tragis",
+ "miris", "prihatin", "keprihatinan", "sedih", "menyedihkan", "susah",
+ "kesusahan", "sulit", "kesulitan", "sengsara", "menderita",
+ "penderitaan", "korban", "dizalimi", "ditindas", "menindas",
+ "penindasan", "kriminal", "pelanggaran", "melanggar", "ilegal",
+ "pungli", "sogok", "menyuap", "disuap", "suap", "nepotisme", "kkn",
+ "provokasi", "provokator", "memprovokasi", "fitnah", "memfitnah",
+ "ancaman", "mengancam", "teror", "intimidasi", "mengintimidasi",
+ "brutal", "kekerasan", "sadis", "tragedi", "bencana", "krisis",
+ "darurat", "gawat", "resah", "keresahan", "meresahkan", "cemas",
+ "kecemasan", "khawatir", "kekhawatiran", "takut", "ketakutan", "geram",
+ "kesal", "jengkel", "dongkol", "malu", "hina", "menghina",
+ "penghinaan", "murahan", "norak", "kampungan", "terbelakang", "mundur",
+ "kemunduran", "ambruk", "bangkrut", "kebangkrutan", "defisit",
+ "terlilit", "terjerat", "terjebak", "cengeng", "lemah", "kelemahan",
+ "pengecut", "penakut", "plin-plan", "labil", "egois", "keegoisan",
+ "sombong", "kesombongan", "angkuh", "arogan", "sok", "songong",
+ "kurang ajar", "tidak becus", "amburadul", "berantakan", "semrawut",
+ "menyengsarakan", "represif", "represi", "diskriminasi",
+ "mendiskriminasi", "rasis", "rasisme", "intoleran", "intoleransi",
+ "penjilat", "gila", "kontol", "memek", "paok", "stress", "goblog",
+ "kepala batu", "oon",
+}
+
+# Flips the polarity of a sentiment word found within NEGATION_WINDOW tokens
+# after it ("tidak bagus" -> negative even though "bagus" alone is positive).
+# Indonesian often puts more distance between the negation and the word it
+# actually governs than English does — "ngga ngajarin cara ngmg yg sopan"
+# ("doesn't teach how to speak politely") is 5 tokens from negation to the
+# sentiment word it negates. A wider window catches more of those at the
+# cost of occasionally flipping something the negation wasn't really about;
+# lexicon scoring is a heuristic either way, this just picks which failure
+# mode to lean toward.
+NEGATION_WORDS = {"tidak", "tak", "bukan", "belum", "jangan", "nggak", "ga", "gak", "kagak", "ngga"}
+NEGATION_WINDOW = 5
+
+# Boosts a sentiment word's weight rather than changing its polarity.
+INTENSIFIERS = {"sangat", "banget", "sekali", "sungguh", "amat", "terlalu", "sangatlah"}
+INTENSIFIER_MULTIPLIER = 1.5
+
+STOPWORDS = {
+ "yang", "dan", "di", "ke", "dari", "untuk", "dengan", "ini", "itu", "ya",
+ "nya", "adalah", "akan", "saya", "kamu", "kita", "kami", "mereka", "dia",
+ "juga", "saja", "sudah", "belum", "atau", "karena", "jika", "kalau",
+ "agar", "supaya", "pada", "oleh", "dalam", "luar", "atas", "bawah",
+ "antara", "seperti", "sebagai", "tentang", "bahwa", "namun", "tetapi",
+ "tapi", "hingga", "sampai", "sejak", "setelah", "sebelum", "ketika",
+ "saat", "ada", "punya", "milik", "lah", "kah", "pun", "deh", "dong",
+ "sih", "kok", "loh", "nih", "gitu", "gini", "dsb", "dll", "dst", "yg",
+ "utk", "dgn", "krn", "gak", "ga", "nggak", "tak", "tidak", "bukan",
+ "jangan", "apa", "apakah", "siapa", "mengapa", "kenapa", "bagaimana",
+ "dimana", "kapan", "para", "si", "sang", "an", "kan", "in", "the", "is",
+ "are", "was", "were", "be", "been", "being", "to", "of", "for", "on",
+ "with", "as", "by", "at", "an", "a", "rt",
+} | NEGATION_WORDS | INTENSIFIERS
+
+_WORD_RE = re.compile(r"[a-zA-ZÀ-ÿ]+(?:-[a-zA-ZÀ-ÿ]+)?")
+_URL_RE = re.compile(r"https?://\S+")
+_MENTION_RE = re.compile(r"@\w+")
+
+
+def _tokenize(text: str) -> list[str]:
+ """Lowercased word tokens with URLs/@mentions stripped first (both would
+ otherwise pollute the lexicon match and the word cloud with usernames/
+ link fragments neither list has any business scoring)."""
+ if not text:
+ return []
+ cleaned = _URL_RE.sub(" ", text)
+ cleaned = _MENTION_RE.sub(" ", cleaned)
+ return [w.lower() for w in _WORD_RE.findall(cleaned)]
+
+
+def score_text(text: str) -> dict:
+ """Returns {label, score, matches} for one piece of text. label is one of
+ "pro" / "neutral" / "con". matches lists (word, polarity, weight) for
+ every lexicon hit, so a classification can be inspected rather than
+ trusted blindly — a defining trait of a rule-based classifier is that you
+ CAN see exactly why it decided what it decided."""
+ tokens = _tokenize(text)
+ score = 0.0
+ matches = []
+
+ for i, tok in enumerate(tokens):
+ polarity = 1 if tok in POSITIVE_WORDS else -1 if tok in NEGATIVE_WORDS else 0
+ if polarity == 0:
+ continue
+
+ weight = 1.0
+ # Negation: any negation word in the preceding window flips polarity.
+ window_start = max(0, i - NEGATION_WINDOW)
+ if any(t in NEGATION_WORDS for t in tokens[window_start:i]):
+ polarity = -polarity
+ # Intensifier: any intensifier immediately before boosts magnitude
+ # (checked after negation so "tidak sangat bagus" still flips first).
+ if i > 0 and tokens[i - 1] in INTENSIFIERS:
+ weight = INTENSIFIER_MULTIPLIER
+
+ contribution = polarity * weight
+ score += contribution
+ matches.append({"word": tok, "polarity": "pro" if polarity > 0 else "con", "weight": weight})
+
+ label = "pro" if score > 0 else "con" if score < 0 else "neutral"
+ return {"label": label, "score": round(score, 2), "matches": matches}
+
+
+def _item_text(item: dict) -> str:
+ """The text worth scoring/tokenizing for a given archived record —
+ varies by which tool produced it (a tweet's own text vs. a Wayback/CSE
+ page's scraped title+description vs. a bare user's bio)."""
+ parts = [
+ item.get("text"), item.get("full_text"), item.get("article_text"),
+ item.get("post_title"), item.get("post_text"), item.get("description"),
+ ]
+ return " ".join(p for p in parts if p)
+
+
+def _item_author(item: dict) -> str | None:
+ return item.get("screen_name") or item.get("user") or item.get("name")
+
+
+def _item_engagement(item: dict) -> int:
+ total = 0
+ for k in ("reply_count", "retweet_count", "favorite_count"):
+ v = item.get(k)
+ if isinstance(v, (int, float)):
+ total += v
+ return total
+
+
+def word_frequencies(items: list[dict], top_n: int = 60) -> list[dict]:
+ """Word-cloud data: [{word, count}], most frequent first. Stopwords and
+ single-character tokens are dropped; everything else counts regardless
+ of whether it happened to be in the sentiment lexicon."""
+ counts = Counter()
+ for item in items:
+ for tok in _tokenize(_item_text(item)):
+ if len(tok) < 3 or tok in STOPWORDS:
+ continue
+ counts[tok] += 1
+ return [{"word": w, "count": c} for w, c in counts.most_common(top_n)]
+
+
+def top_users(items: list[dict], top_n: int = 20) -> list[dict]:
+ """Who shows up most often across the archive — every record with an
+ identifiable author counts once, regardless of whether it's a tweet, a
+ reply, a retweeter entry, or a bare follower/following record. Carries
+ along the most recently seen avatar/name for that handle so the
+ dashboard can show a face, not just a bare count."""
+ counts: Counter = Counter()
+ display: dict[str, dict] = {}
+ for item in items:
+ handle = item.get("screen_name") or item.get("user")
+ if not handle:
+ continue
+ counts[handle] += 1
+ display[handle] = {
+ "screen_name": handle,
+ "name": item.get("name"),
+ "avatar": item.get("avatar") or item.get("user_avatar"),
+ "verified": item.get("verified"),
+ "is_blue_verified": item.get("is_blue_verified"),
+ }
+ ranked = []
+ for handle, count in counts.most_common(top_n):
+ ranked.append({**display[handle], "count": count})
+ return ranked
+
+
+def top_engagement(items: list[dict], top_n: int = 15) -> list[dict]:
+ """Which posts drove the most reply+retweet+favorite activity — "paling
+ ramai" (busiest/most-discussed), not just most recent."""
+ scored = [(_item_engagement(it), it) for it in items if _item_text(it)]
+ scored.sort(key=lambda pair: pair[0], reverse=True)
+ return [{"engagement": score, "item": it} for score, it in scored[:top_n] if score > 0]
+
+
+# ── ML backend (preferred) ───────────────────────────────────────────────────
+# Lazy-loaded: importing torch/transformers and loading ~1.1GB of weights is
+# slow, and both packages are optional (requirements.txt notes how to add
+# them) — doing this at module import time would slow down every single use
+# of this app, including ones that never touch analytics, and would hard-crash
+# an install that skipped the ML deps entirely instead of just falling back.
+_ML_MODEL_NAME = "cardiffnlp/twitter-xlm-roberta-base-sentiment"
+_ML_LABEL_MAP = {"positive": "pro", "negative": "con", "neutral": "neutral"}
+_ml_pipeline = None
+_ml_unavailable = False # sticky once loading fails — stop retrying every call
+_ml_load_lock = threading.Lock()
+
+
+def _get_ml_pipeline():
+ global _ml_pipeline, _ml_unavailable
+ if _ml_pipeline is not None or _ml_unavailable:
+ return _ml_pipeline
+ with _ml_load_lock:
+ if _ml_pipeline is not None or _ml_unavailable: # re-check post-lock
+ return _ml_pipeline
+ try:
+ from transformers import pipeline
+ _ml_pipeline = pipeline("sentiment-analysis", model=_ML_MODEL_NAME)
+ except Exception:
+ # Missing torch/transformers, no internet for the first-time
+ # model download, out of memory, ... any of these should fall
+ # back to the lexicon scorer rather than take the whole
+ # analytics endpoint down.
+ _ml_unavailable = True
+ _ml_pipeline = None
+ return _ml_pipeline
+
+
+def _score_texts_ml(texts: list[str]) -> list[dict] | None:
+ """Batch-scores every text in one call (far faster on CPU than one
+ pipeline call per item). Returns None if the model isn't available, so
+ the caller falls back to the lexicon scorer instead. `score` is signed
+ (positive for pro, negative for con, 0 for neutral) to match the
+ lexicon backend's convention; `confidence` carries the model's own
+ unsigned probability for the label it picked."""
+ clf = _get_ml_pipeline()
+ if clf is None:
+ return None
+ raw = clf(texts, truncation=True, batch_size=16)
+ results = []
+ for r in raw:
+ label = _ML_LABEL_MAP.get(str(r.get("label", "")).lower(), "neutral")
+ confidence = float(r.get("score", 0.0))
+ signed = confidence if label == "pro" else -confidence if label == "con" else 0.0
+ results.append({
+ "label": label, "score": round(signed, 3),
+ "confidence": round(confidence, 3), "matches": [],
+ })
+ return results
+
+
+def analyze(items: list[dict]) -> dict:
+ """Full analytics payload for one archive's worth of raw items."""
+ if not isinstance(items, list):
+ items = [items]
+ # archive.py's own _run() passes non-dict entries through as-is rather
+ # than dropping them (see its "if not isinstance(item, dict)" branch),
+ # so a saved archive can legitimately contain a stray non-dict item —
+ # every function below assumes dict.get(), so those get filtered here
+ # once rather than each helper needing its own isinstance guard.
+ items = [it for it in items if isinstance(it, dict)]
+
+ text_items, texts = [], []
+ for item in items:
+ text = _item_text(item)
+ if text: # skip e.g. a bare follower/following record with no post text of its own
+ text_items.append(item)
+ texts.append(text)
+
+ ml_results = _score_texts_ml(texts) if texts else None
+ method = "ml" if ml_results is not None else "lexicon"
+ if ml_results is None:
+ ml_results = [score_text(t) for t in texts]
+
+ sentiment_counts = {"pro": 0, "neutral": 0, "con": 0}
+ scored_items = []
+ for item, text, result in zip(text_items, texts, ml_results):
+ sentiment_counts[result["label"]] += 1
+ scored_items.append({
+ "label": result["label"],
+ "score": result["score"],
+ "confidence": result.get("confidence"),
+ "matches": result.get("matches", []),
+ "author": _item_author(item),
+ "text": text,
+ "item": item,
+ })
+
+ total = len(scored_items)
+ return {
+ "method": method,
+ "total_items": len(items),
+ "total_scored": total,
+ "sentiment_counts": sentiment_counts,
+ "sentiment_pct": {
+ k: round(v / total * 100, 1) if total else 0.0
+ for k, v in sentiment_counts.items()
+ },
+ "scored_items": scored_items,
+ "word_freq": word_frequencies(items),
+ "top_users": top_users(items),
+ "top_engagement": top_engagement(items),
+ }
diff --git a/Script/SOCMINT-Twitter/static/js/card_constants.js b/Script/SOCMINT-Twitter/static/js/card_constants.js
index ad45db0..e464744 100644
--- a/Script/SOCMINT-Twitter/static/js/card_constants.js
+++ b/Script/SOCMINT-Twitter/static/js/card_constants.js
@@ -8,7 +8,7 @@
// Arr params
const PRIORITY = [
- 'source', 'account_age_flag', 'account_age', 'account_created',
+ 'source', 'content_type', 'account_age_flag', 'account_age', 'account_created',
'user', 'screen_name', 'name', 'user_id', 'username',
'verified', 'is_blue_verified',
'text', 'full_text', 'article_text', 'post_title', 'post_text', 'content', 'title', 'description', 'bio',
@@ -18,9 +18,23 @@ const PRIORITY = [
'retweeted_by_user', 'retweeted_by_name', 'retweeted_text', 'retweeted_by_bio', 'retweeted_at', 'retweeted_tweet_id',
'lat', 'lon', 'place', 'user_location',
'tweet_url', 'archive_url', 'result_url', 'preview_image', 'display_link',
+ 'serp_title',
'iso_date', 'original', 'statuscode', 'mimetype', 'length',
];
+// content_type: what kind of X/Twitter page a Google CSE / Wayback result
+// actually is — a search hit for a keyword could be a specific tweet, a
+// bare profile page, some other X page, or (Google CSE only) a site off X
+// entirely. Cookie/Xquik records don't carry this field at all (a tweet
+// search result there is unambiguously always a tweet), so it only ever
+// shows up for the two sources it's meant to disambiguate.
+const CONTENT_TYPE_LABELS = {
+ tweet: 'Tweet',
+ profile: 'Profile page',
+ twitter_other: 'Other X/Twitter page',
+ other: 'External page (non-X)',
+};
+
// Which fields link out to a fresh extraction for that tweet — same anchor
// behavior whether you're looking at a live result or a saved archive.
const DRILLABLE = {
diff --git a/Script/SOCMINT-Twitter/templates/_field_glossary.html b/Script/SOCMINT-Twitter/templates/_field_glossary.html
index 3d7a37a..1b4081a 100644
--- a/Script/SOCMINT-Twitter/templates/_field_glossary.html
+++ b/Script/SOCMINT-Twitter/templates/_field_glossary.html
@@ -4,10 +4,13 @@
id / user_id Twitter's internal numeric ID for the tweet / account
user / screen_name The @handle
name Display name
+
avatar / banner Profile photo / cover photo — direct CDN links, cookie mode only. On a tweet/reply record these are the author's photos, named user_avatar/user_banner instead so they don't collide with the tweet's own fields
Content
-
text / full_text / article_text The post's own text
-
description Profile bio
+
text / full_text / article_text The post's own complete text — cookie mode always resolves the real full text even for long-form "Note" tweets that X itself truncates in its legacy API field
+
reply_to_mentions The @handles X auto-prepended to a reply's text when it was composed (everyone already tagged in that conversation) — x.com's own UI hides these from the visible tweet body and shows them as a separate "Replying to" line instead, which is exactly what every card/panel here does with this field too. The raw text itself still contains them untouched
+
hashtags Hashtags used in the post
+
description Profile bio (a user/follower/following record), or Google's own SERP snippet for that result (a CSE record) — which one depends on whether the record is a person or a web result
created_at When the post itself was actually made. Same field, every source: Cookie/Xquik get it straight from the API; Wayback and Google CSE decode it from the tweet id's Snowflake bits whenever the result links to a tweet permalink (absent otherwise — e.g. a profile page or a non-X result, where there's no post id to decode)
fetched_at When this tool pulled the record — same field, every source (Cookie, Xquik, Wayback, Google CSE), so results are comparable side by side. Not to be confused with created_at (the post's own creation time) or iso_date (a Wayback snapshot's capture time)
@@ -47,9 +50,11 @@
statuscode / mimetype / length HTTP status / content type / size of the snapshot
post_title / post_text / preview_image Scraped from the archived page's own meta tags (og:/twitter: tags, or <title>/<meta name="description"> on older captures)
-
Google CSE
-
post_title Result title from Google
-
post_text The page's own og:/twitter:/meta description, fetched live from result_url — not Google's own SERP snippet, which is usually clipped mid-sentence. Falls back to Google's snippet only if that live fetch fails
+
Google CSE / Wayback
+
content_type What kind of X/Twitter page a result actually is — tweet (a specific permalink) / profile (a bare profile page) / twitter_other (some other X page) / other (a non-X site, Google CSE only). Derived purely from the result URL's own shape — a keyword match isn't always a specific tweet, and this is how to tell without opening the link
+
serp_title Google's own SERP result title — always kept as-is, even if post_title below gets replaced by a fresher live fetch
+
description Google's own SERP snippet for this result — Google truncates this itself (ends mid-sentence in "…"), that's not this tool cutting it short. Kept untouched alongside post_text below since a live re-fetch of an X link often gets a less specific, bot-facing page instead
+
post_title / post_text "Best known" title/description — starts as Google's own SERP values, replaced by a live fetch of the page's own og:/twitter:/meta tags when one succeeds. See serp_title/description above for what Google originally had, in case the live fetch made things worse rather than better
result_url The live page Google indexed, shown as its full raw address
display_link The result's domain, as shown in Google's own results
preview_image Thumbnail — from Google's own indexed metadata, or the live page's og:/twitter:image when Google didn't have one
@@ -59,9 +64,9 @@
Graph node types (graph page only)
Search root The diamond node — the query you ran
-
Tweet A tweet/post returned by search or by expanding a node
+
Tweet A tweet/post returned by search, by Expand Posts on a user node, or by expanding another node
Reply A tweet fetched via Expand Replies on a tweet node
-
User / Retweeter A person — from Follower Explorer, or via Expand Retweets on a tweet node
+
User / Retweeter A person — from Follower/Following Explorer, via Expand Retweets/Followers/Following on a node, or via View Author Profile on a tweet/reply (pulled from that tweet's already-fetched author info, no extra request). Once on canvas, select it and Expand Posts/Followers/Following works the same regardless of how it got there
Wayback snapshot An archived-page result, from Wayback Archive Search or the Wayback portion of Multi-Source Search
Web result A Google CSE result, from the Google CSE portion of Multi-Source Search
Viewed Turns a node solid white once you've clicked it — a visual "already looked at this" marker, not part of the underlying data
Google CSE Live web result via Google Custom Search
+
Content type — Google CSE / Wayback only
+
Tweet A specific tweet permalink
+
Profile page A bare account profile, not a specific post
+
Other X/Twitter page Some other X page
+
External page (non-X) A site off X entirely (Google CSE only)
Account age
New account Created < 30 days ago
Recent account Created < 1 year ago
@@ -498,6 +546,18 @@
popup.addEventListener('click', function(e) { e.stopPropagation(); });
}());
+// Back to top — the page itself is what scrolls (the sidebar/viewer split
+// is a grid column layout, not a fixed-height scroll pane).
+(function() {
+ var btn = document.getElementById('backToTop');
+ window.addEventListener('scroll', function() {
+ btn.classList.toggle('visible', window.scrollY > 400);
+ });
+ btn.addEventListener('click', function() {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+}());
+
let allItems = [];
let activeId = null;
let allArchives = []; // full list from the server; archiveSearch filters this client-side
@@ -567,6 +627,7 @@ function entryHtml(a) {
return `
${esc(tool)}
+
${esc(date)}
${query ? `
${esc(query)}
` : ''}
· ${a.total_items || 0} items
@@ -674,6 +735,8 @@ function buildCard(item) {
display = `${esc(String(v))}`;
} else if (k === 'account_age_flag') {
display = `${esc(AGE_LABELS[v] || v)}`;
+ } else if (k === 'content_type') {
+ display = `${esc(CONTENT_TYPE_LABELS[v] || String(v))}`;
} else if (typeof v === 'object') {
const s = JSON.stringify(v);
display = `${esc(s.length > 100 ? s.slice(0,100)+'…' : s)}`;
diff --git a/Script/SOCMINT-Twitter/templates/graph.html b/Script/SOCMINT-Twitter/templates/graph.html
index 0a0e57d..5e65277 100644
--- a/Script/SOCMINT-Twitter/templates/graph.html
+++ b/Script/SOCMINT-Twitter/templates/graph.html
@@ -227,6 +227,56 @@
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
+ .panel-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-bottom: 12px;
+ margin-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ }
+ .panel-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--border);
+ flex-shrink: 0;
+ background: var(--bg);
+ }
+ .panel-avatar-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--muted);
+ }
+ .panel-identity { min-width: 0; }
+ .panel-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .panel-handle { font-size: 11px; color: var(--muted); }
+ /* Compact variant for tweet/reply nodes — the node's own text is a row
+ below, so this stays a small identity line rather than a full profile
+ block (that treatment is reserved for user/retweeter nodes). */
+ .panel-byline { padding-bottom: 10px; }
+ .panel-byline .panel-avatar { width: 28px; height: 28px; }
+ .panel-byline .panel-identity { display: flex; align-items: baseline; gap: 6px; }
+ .panel-bio {
+ font-size: 11px;
+ color: var(--text);
+ opacity: 0.85;
+ line-height: 1.5;
+ padding-bottom: 10px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid var(--border);
+ }
.info-row {
display: flex;
gap: 8px;
@@ -252,6 +302,14 @@
}
.info-val a { color: var(--accent); text-decoration: none; }
.info-val a:hover { text-decoration: underline; }
+ /* "Replying to @x @y" — x.com trims these from the visible tweet body and
+ shows them as their own line instead; matches that here. Display-only,
+ the underlying text value (Dump JSON / Archive All) is untouched. */
+ .reply-context {
+ font-size: 10px;
+ color: var(--muted);
+ margin-bottom: 4px;
+ }
.info-media {
display: flex;
flex-wrap: wrap;
@@ -341,6 +399,8 @@
.leg-icon.wayback { background: #2a1f08; border-color: var(--warn); border-radius: 2px; }
.leg-icon.cse { background: #2a0d0d; border-color: #ef4444; border-radius: 2px; }
.leg-icon.viewed { background: #ffffff; border-color: #c7c7c7; border-radius: 2px; }
+ .leg-line { width: 20px; height: 0; flex-shrink: 0; border-top: 2px solid; }
+ .leg-line-correlation { border-top-color: #facc15; }
.leg-heading {
font-size: 10px;
text-transform: uppercase;
@@ -397,6 +457,23 @@
.age-badge.age-recent { color: var(--warn); border-color: #78350f; background: #1c0e02; }
.age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }
+ /* What kind of X/Twitter page a Google CSE / Wayback result actually is —
+ a keyword match linking to x.com/someone reads as "just a profile" with
+ no way to tell a tweet permalink apart from a bare profile page or an
+ unrelated page otherwise. */
+ .content-type-badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 9px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ }
+ .content-type-badge.ct-tweet { color: var(--success); border-color: #14532d; background: #052011; }
+ .content-type-badge.ct-profile { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
+ .content-type-badge.ct-twitter_other { color: var(--warn); border-color: #78350f; background: #1c0e02; }
+ .content-type-badge.ct-other { color: var(--muted); border-color: var(--border); background: var(--bg); }
+
/* ── Status bar ── */
#statusBar {
position: absolute;
@@ -467,6 +544,7 @@
GraphArchives
+ Analytics
@@ -474,6 +552,7 @@
+
Controls
+
Drag background to pan · Scroll to zoom · Click a node to inspect it Shift/Alt/Ctrl+drag to box-select multiple nodes, then drag any one to move the whole selection together
+
Search root
Tweet
Reply
-
User / Retweeter
+
User / Retweeter / Follower / Following
Wayback snapshot
Google CSE web result
Viewed (clicked)
+
Double border = this tweet/reply has replies of its own — click it, then Expand Replies to go a level deeper. Works on reply nodes too, so a thread nests as deep as it actually goes.
+
A user/retweeter node's photo is their real avatar when one was captured (cookie mode) — the plain purple circle is the fallback when it wasn't.
+
+
Expanding a node further
+
Tweet/Reply — Expand Replies, Expand Retweets, or View Author Profile (pulls the author out as its own node, free — no request, the data's already on the tweet). User/Retweeter — Expand Posts, Expand Followers, or Expand Following. Works on a "View Author" node too, so a reply's author can be looked up in full: posts, followers, following.
+
Edge labels show how two nodes relate once expanded: replied by, retweeted by, posted, followed by, follows, authored by. A plain unlabeled edge just means "matched the search."
+
Correlation — this connects to a node already reached some other way (e.g. a reply's author who also turns up as a follower). Marks where two separate paths through the graph converge on the same account/tweet.
Source (info panel)
Cookie
@@ -545,6 +640,13 @@
Wayback Machine
Google CSE
+
Content type — Google CSE / Wayback only (info panel)
+
Tweet
+
Profile page
+
Other X/Twitter page
+
External page (non-X)
+
What kind of page a keyword match actually landed on — a search hit isn't always a specific tweet.
+
Account age (info panel)
New < 30 days
Recent < 1 year
@@ -646,6 +748,33 @@ const CY_STYLE = [
'background-color': '#ffffff',
'border-color': '#c7c7c7',
}},
+ // Avatar texture — replaces the flat fill with the person's actual profile
+ // photo when one was captured (cookie mode only; xquik/API-mode records
+ // don't carry it). The colored ring from the selectors above stays as a
+ // type indicator; only the fill becomes a photo. Declared after the
+ // "viewed" block so the photo wins regardless of viewed state.
+ { selector: 'node[type="user"][?hasAvatar], node[type="retweeter"][?hasAvatar]', style: {
+ 'background-image': 'data(avatarUrl)',
+ 'background-fit': 'cover',
+ 'background-clip': 'node',
+ }},
+ // Nested-reply indicator — a slightly thicker, double-lined border marks a
+ // tweet/reply node that has further replies of its own to expand into,
+ // glanceable on the canvas without opening the info panel.
+ { selector: 'node[?hasReplies]', style: {
+ 'border-width': '3px',
+ 'border-style': 'double',
+ }},
+ // Multi-select highlight (Maltego-style box select) — wins over every
+ // type's own border-color above since it's declared last in this array
+ // (Cytoscape applies matching selectors in declaration order, last wins).
+ { selector: 'node:selected', style: {
+ 'border-width': '3px',
+ 'border-color': '#5865f2',
+ 'overlay-color': '#5865f2',
+ 'overlay-opacity': 0.22,
+ 'overlay-padding': '6px',
+ }},
// Softer, thinner and semi-transparent — a hub with many edges (a popular
// tweet's replies, a big multi-source search) stays legible instead of
// turning into a bright white tangle.
@@ -658,6 +787,30 @@ const CY_STYLE = [
'target-arrow-shape': 'triangle',
'arrow-scale': 1.0,
'curve-style': 'bezier',
+ // Empty for a plain "matched the search" edge (addEdge's label
+ // defaults to ''), so most edges in a fresh search still draw with no
+ // visible text — this only shows up on the relationship-specific
+ // edges node-expand actions create (replied by / retweeted by /
+ // posted / followed by / follows / authored by).
+ label: 'data(label)',
+ 'font-size': '8px',
+ color: '#c7c7c7',
+ 'text-rotation': 'autorotate',
+ 'text-background-color': '#0f1117',
+ 'text-background-opacity': 0.85,
+ 'text-background-padding': '2px',
+ }},
+ // Correlation — this edge connects to a node that already existed via
+ // some OTHER path (a different search, a different expand, ...) rather
+ // than one just discovered. Declared after the base 'edge' selector so it
+ // wins on line/arrow color; solid + thicker on top of the color change so
+ // it's still readable once a dense graph desaturates everything else.
+ { selector: 'edge[?correlation]', style: {
+ 'line-color': '#facc15',
+ 'line-style': 'solid',
+ width: '1.8px',
+ 'target-arrow-color': '#facc15',
+ color: '#facc15',
}},
];
@@ -670,7 +823,14 @@ function init() {
minZoom: 0.08,
maxZoom: 4,
wheelSensitivity: 0.25,
- boxSelectionEnabled: false,
+ // Maltego-style multi-select: hold shift/alt/ctrl/cmd and drag on empty
+ // canvas to rubber-band select several nodes at once, then drag any one
+ // of them to move the whole selection together — this is native
+ // Cytoscape drag behavior once elements are selected, no extra code
+ // needed. Plain drag-on-background still pans (Cytoscape requires a
+ // modifier key for box select whenever panning is also enabled, so the
+ // two gestures never conflict).
+ boxSelectionEnabled: true,
// Keep panning/zooming smooth once a graph has hundreds of nodes/edges —
// edges & a rasterized texture stand in for the full render mid-gesture.
hideEdgesOnViewport: true,
@@ -687,6 +847,18 @@ function init() {
if (evt.target === cy) hidePanel();
});
+ // Multi-select bookkeeping — the single-node info panel only makes sense
+ // for exactly one selected node, so a box-select of several hides it
+ // (without touching the selection itself, unlike hidePanel()) and the
+ // node-count badge picks up the slack to show what's selected.
+ cy.on('select unselect', 'node', function () {
+ updateNodeCount();
+ if (cy.nodes(':selected').length > 1) {
+ document.getElementById('infoPanel').classList.add('hidden');
+ selectedNode = null;
+ }
+ });
+
// Hamburger nav
var navBtn = document.getElementById('navToggle');
var navMenu = document.getElementById('navMenu');
@@ -725,6 +897,17 @@ function init() {
document.getElementById('btnExpandRetweets').addEventListener('click', function () {
expandNode('tweet_retweeters_extractor');
});
+ document.getElementById('btnViewAuthor').addEventListener('click', viewAuthor);
+ document.getElementById('btnExpandPosts').addEventListener('click', function () {
+ expandNode('post_extractor');
+ });
+ document.getElementById('btnExpandFollowers').addEventListener('click', function () {
+ expandNode('follower_explorer');
+ });
+ document.getElementById('btnExpandFollowing').addEventListener('click', function () {
+ expandNode('following_explorer');
+ });
+ document.getElementById('btnLoadMoreSearch').addEventListener('click', expandSearch);
updatePlaceholder();
updateModeVisibility();
@@ -735,6 +918,7 @@ function updatePlaceholder() {
var map = {
tweet_search_extractor: 'Search query…',
follower_explorer: 'Username or user ID…',
+ following_explorer: 'Username or user ID…',
post_extractor: 'Username or user ID…',
article_extractor: 'Tweet ID…',
community_post_extractor: 'Community ID…',
@@ -776,8 +960,11 @@ function setStatus(msg, isError, persist) {
}
function updateNodeCount() {
- var n = cy.nodes().length;
- document.getElementById('nodeCount').textContent = n + ' node' + (n !== 1 ? 's' : '');
+ var n = cy.nodes().length;
+ var selN = cy.nodes(':selected').length;
+ var label = n + ' node' + (n !== 1 ? 's' : '');
+ if (selN > 1) label += ' · ' + selN + ' selected';
+ document.getElementById('nodeCount').textContent = label;
document.getElementById('emptyHint').classList.toggle('hidden', n > 0);
}
@@ -787,7 +974,7 @@ function buildBody(tool, query, count, mode) {
if (tool === 'tweet_search_extractor' || tool === 'geo_post_extractor' ||
tool === 'wayback_archive_search' || tool === 'multi_source_search') {
body.searchQuery = query;
- } else if (tool === 'follower_explorer' || tool === 'post_extractor') {
+ } else if (tool === 'follower_explorer' || tool === 'following_explorer' || tool === 'post_extractor') {
body.targetUsername = query;
} else if (
tool === 'article_extractor' ||
@@ -832,19 +1019,50 @@ async function apiFetch(body) {
var cooldownUntil = { cookie: 0, wayback: 0 };
var cooldownTimer = null;
+// Returns the list of rate-limit clocks a request against this tool/mode
+// touches. multi_source_search fans out to both cookie and wayback
+// internally, so continuing it has to wait out (and then restart) both
+// cooldowns at once, not just one.
function throttleSourceFor(tool, mode) {
- if (tool === 'wayback_archive_search') return 'wayback';
- var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'post_extractor',
+ if (tool === 'wayback_archive_search') return ['wayback'];
+ if (tool === 'multi_source_search') return ['cookie', 'wayback'];
+ var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor', 'geo_post_extractor'];
- if (mode === 'cookie' && COOKIE_TOOLS.indexOf(tool) !== -1) return 'cookie';
- return null;
+ if (mode === 'cookie' && COOKIE_TOOLS.indexOf(tool) !== -1) return ['cookie'];
+ return [];
}
-function stampCooldown(source) {
- if (source) cooldownUntil[source] = Date.now() + 5000;
+function stampCooldown(sources) {
+ (sources || []).forEach(function (source) { cooldownUntil[source] = Date.now() + 5000; });
}
+// Tools whose root search (not just node-expand) supports cursor pagination
+// — everything except article_extractor, which only ever returns one item.
+var ROOT_PAGINATED_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
+ 'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor',
+ 'geo_post_extractor', 'wayback_archive_search', 'multi_source_search'];
+
// ── Label builder ─────────────────────────────────────────────────────────────
+// Tapping "Reply" on X auto-prefixes the compose box with every account the
+// reply-chain already has tagged, and that prefix is genuinely part of the
+// reply's own raw text — but x.com's own UI never shows it inline, trimming
+// it into a separate "Replying to @x @y" line instead. `reply_to_mentions`
+// (from cookie_client.py's _leading_reply_mentions) lists exactly which
+// leading @mentions those are; this only touches how text RENDERS — the
+// node's own raw data (and what Dump JSON / Archive All send) is untouched.
+function stripLeadingMentions(text, mentions) {
+ if (!text || !mentions || !mentions.length) return text;
+ var rest = text;
+ for (var i = 0; i < mentions.length; i++) {
+ var re = new RegExp('^\\s*@' + String(mentions[i]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
+ var match = rest.match(re);
+ if (!match) break;
+ rest = rest.slice(match[0].length);
+ }
+ rest = rest.replace(/^\s+/, '');
+ return rest || text;
+}
+
function makeLabel(item, type) {
if (type === 'user' || type === 'retweeter') {
var handle = item.screen_name || item.user || '';
@@ -858,7 +1076,8 @@ function makeLabel(item, type) {
var web = (item.post_title || item.post_text || item.display_link || 'Web result').replace(/\s+/g, ' ').trim();
return web.length > 60 ? web.substring(0, 60) + '…' : web;
}
- var text = (item.text || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
+ var rawText = item.text ? stripLeadingMentions(item.text, item.reply_to_mentions) : item.text;
+ var text = (rawText || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
return text.length > 60 ? text.substring(0, 60) + '…' : (text || ('Tweet ' + (item.id || '?')));
}
@@ -866,7 +1085,7 @@ function makeLabel(item, type) {
// snapshots and Google CSE web results in one result set, so the type has to
// be resolved per item rather than fixed for the whole tool/search.
function resolveNodeType(tool, item) {
- if (tool === 'follower_explorer') return 'user';
+ if (tool === 'follower_explorer' || tool === 'following_explorer') return 'user';
if (tool === 'tweet_retweeters_extractor') return 'retweeter';
if (tool === 'wayback_archive_search') return 'wayback';
if (tool === 'multi_source_search') {
@@ -877,8 +1096,21 @@ function resolveNodeType(tool, item) {
return 'tweet';
}
+// Only a plain https URL is ever handed to Cytoscape's background-image —
+// it ends up as a CSS url() token internally, so the same reasoning as
+// index.html's isSafeImageUrl applies: reject anything but a clean https
+// URL rather than trying to escape one value for two contexts.
+function isSafeImageUrl(u) {
+ return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
+}
+
// ── Graph operations ──────────────────────────────────────────────────────────
-function addNodes(items, nodeTypeOrFn, parentId) {
+// edgeLabel is optional — omitted for a plain "this matched the search"
+// connection (search root → result, continued-search pagination); every
+// node-expand action (replies/retweets/posts/followers/following/author)
+// passes one, since with 6+ relationship types now a bare arrow stopped
+// being enough to tell what an edge actually means.
+function addNodes(items, nodeTypeOrFn, parentId, edgeLabel) {
var added = 0;
items.forEach(function (item, i) {
var nodeType = typeof nodeTypeOrFn === 'function' ? nodeTypeOrFn(item) : nodeTypeOrFn;
@@ -886,31 +1118,48 @@ function addNodes(items, nodeTypeOrFn, parentId) {
var cyId = tweetId ? 'n_' + tweetId : 'n_' + Date.now() + '_' + i;
if (cy.getElementById(cyId).length) {
- if (parentId) addEdge(parentId, cyId);
+ // The node this would-be edge points to already exists — reached via
+ // some OTHER path already (a different search, a different expand, a
+ // reply's author who also turned up as a follower, ...). That's a
+ // genuine correlation between two otherwise-separate parts of the
+ // graph, worth calling out on its own rather than blending in with
+ // every other edge once the graph gets big.
+ if (parentId) addEdge(parentId, cyId, edgeLabel, true);
return;
}
+ var avatarUrl = item.avatar || item.user_avatar || '';
+ if (!isSafeImageUrl(avatarUrl)) avatarUrl = '';
+
cy.add({
data: {
- id: cyId,
- type: nodeType,
- label: makeLabel(item, nodeType),
- tweetId: tweetId,
- raw: item,
+ id: cyId,
+ type: nodeType,
+ label: makeLabel(item, nodeType),
+ tweetId: tweetId,
+ raw: item,
+ hasReplies: (nodeType === 'tweet' || nodeType === 'reply') && Number(item.reply_count) > 0,
+ hasAvatar: (nodeType === 'user' || nodeType === 'retweeter') && !!avatarUrl,
+ avatarUrl: avatarUrl,
}
});
- if (parentId) addEdge(parentId, cyId);
+ if (parentId) addEdge(parentId, cyId, edgeLabel, false);
allItems.push({ type: nodeType, item: item });
added++;
});
return added;
}
-function addEdge(src, tgt) {
+// A node pair can only ever have one edge between them (id is derived purely
+// from src+tgt) — if a second expand action connects the same two nodes with
+// a different label (e.g. someone who's both a follower and a retweeter),
+// the first label/correlation state that got there wins rather than being
+// silently replaced.
+function addEdge(src, tgt, label, isCorrelation) {
var eId = 'e_' + src + '_' + tgt;
if (!cy.getElementById(eId).length) {
- cy.add({ data: { id: eId, source: src, target: tgt } });
+ cy.add({ data: { id: eId, source: src, target: tgt, label: label || '', correlation: !!isCorrelation } });
}
}
@@ -933,7 +1182,7 @@ function runLayout(fit, randomize) {
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 count = Math.max(1, Math.min(2000, parseInt(document.getElementById('countInput').value, 10) || 20));
var mode = document.getElementById('modeSelect').value;
if (!query) { setStatus('Enter a query first', true); return; }
@@ -945,15 +1194,20 @@ async function runSearch() {
var result = await apiFetch(buildBody(tool, query, count, mode));
var items = Array.isArray(result.items) ? result.items : [result.items];
stampCooldown(throttleSourceFor(tool, mode));
- // Root-search pagination (continuing this same search past one page) is
- // not wired up in the graph UI yet — only reply/retweet node-expand
- // supports "load more" this round. result.nextCursor is intentionally
- // unused here.
- // Central search node
+ // Central search node — carries everything expandSearch() needs to fetch
+ // the next page later (tool/query/mode/count fixed at the time of this
+ // search, cursor updated after every page).
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 } } });
+ cy.add({
+ data: {
+ id: searchId, type: 'search', label: shortQ, tweetId: '', raw: { query: query },
+ tool: tool, query: query, mode: mode, count: count,
+ searchCursor: result.nextCursor || null,
+ searchExhausted: !result.nextCursor,
+ }
+ });
var added = addNodes(items, function (item) { return resolveNodeType(tool, item); }, searchId);
runLayout(true, true);
@@ -966,75 +1220,194 @@ async function runSearch() {
}
}
-// ── Expand replies / retweets from selected node ──────────────────────────────
+// ── Expand a node further (replies/retweets/posts/followers/following) ────────
// First click on a node = fresh fetch. If more is available, the button
// relabels itself and a second click continues from the stored cursor —
// same cursor/cooldown mechanism as the non-graph page's scroll load-more.
-var CURSOR_KEY = { tweet_replies_extractor: 'repliesCursor', tweet_retweeters_extractor: 'retweetersCursor' };
-var EXHAUSTED_KEY = { tweet_replies_extractor: 'repliesExhausted', tweet_retweeters_extractor: 'retweetersExhausted' };
+// targetKind picks which id the API call needs: a tweet node sends its own
+// tweetId as targetTweetId; a user/retweeter node sends the account's own
+// id (preferred) or handle as targetUsername.
+var EXPAND_TOOLS = {
+ tweet_replies_extractor: { targetKind: 'tweet', nodeType: 'reply', button: 'btnExpandReplies', cursorKey: 'repliesCursor', exhaustedKey: 'repliesExhausted', label: 'Replies', edgeLabel: 'replied by', icon: '↩ ', countField: 'reply_count' },
+ tweet_retweeters_extractor: { targetKind: 'tweet', nodeType: 'retweeter', button: 'btnExpandRetweets', cursorKey: 'retweetersCursor', exhaustedKey: 'retweetersExhausted', label: 'Retweets', edgeLabel: 'retweeted by', icon: '↗ ', countField: 'retweet_count' },
+ post_extractor: { targetKind: 'user', nodeType: 'tweet', button: 'btnExpandPosts', cursorKey: 'postsCursor', exhaustedKey: 'postsExhausted', label: 'Posts', edgeLabel: 'posted', icon: '', countField: 'tweet_count' },
+ follower_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowers', cursorKey: 'followersCursor', exhaustedKey: 'followersExhausted', label: 'Followers', edgeLabel: 'followed by', icon: '', countField: 'followers_count' },
+ following_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowing', cursorKey: 'followingCursor', exhaustedKey: 'followingExhausted', label: 'Following', edgeLabel: 'follows', icon: '', countField: 'following_count' },
+};
+
+// Shared by every Expand button in the panel — label reflects whether this
+// node's already been expanded and whether more is available; before the
+// first click, falls back to the account/tweet's own self-reported count
+// (reply_count, followers_count, ...) so it's never a blind "Expand".
+function updateExpandButtonLabel(btn, data, cfg, raw) {
+ if (data[cfg.exhaustedKey]) {
+ btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (all loaded)';
+ } else if (data[cfg.cursorKey]) {
+ btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (more available)';
+ } else {
+ var n = Number(raw[cfg.countField]) || 0;
+ btn.textContent = n > 0 ? cfg.icon + 'Expand ~' + n + ' ' + cfg.label : cfg.icon + 'Expand ' + cfg.label;
+ }
+ btn.disabled = !!data[cfg.exhaustedKey];
+}
+
+function targetIdFor(cfg, data) {
+ if (cfg.targetKind === 'tweet') return data.tweetId || null;
+ var raw = data.raw || {};
+ // user_id is unambiguous (survives a screen_name change); screen_name/user
+ // are the fallback for any record shape that never captured a numeric id.
+ return raw.user_id || raw.id || raw.screen_name || raw.user || null;
+}
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 cfg = EXPAND_TOOLS[expandTool];
+ var data = selectedNode.data();
+ var targetId = targetIdFor(cfg, data);
+ if (!targetId) { setStatus('Node has no ' + (cfg.targetKind === 'tweet' ? 'tweet ID' : 'user ID/handle'), true); return; }
- var source = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — both expand tools are cookie-only
- var remaining = cooldownUntil[source] - Date.now();
+ var sources = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — every expand tool here is cookie-only
+ var btn = document.getElementById(cfg.button);
+ var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
if (remaining > 0) {
- armExpandCountdown(remaining);
+ armCooldownButtons(remaining, [btn], 'Ready — click Expand ' + cfg.label + ' again');
return;
}
- var count = Math.max(1, Math.min(200, parseInt(document.getElementById('countInput').value, 10) || 50));
+ var count = Math.max(1, Math.min(2000, parseInt(document.getElementById('countInput').value, 10) || 50));
var parentId = selectedNode.id();
- var cursor = selectedNode.data(CURSOR_KEY[expandTool]) || null;
+ var cursor = selectedNode.data(cfg.cursorKey) || null;
- document.getElementById('btnExpandReplies').disabled = true;
- document.getElementById('btnExpandRetweets').disabled = true;
+ var body = { toolType: expandTool, mode: 'cookie', count: count };
+ if (cfg.targetKind === 'tweet') body.targetTweetId = targetId;
+ else body.targetUsername = targetId;
+ if (cursor) body.cursor = cursor;
+
+ btn.disabled = true;
setStatus(cursor ? 'Loading more…' : 'Expanding…', false, true);
try {
- var body = { toolType: expandTool, mode: 'cookie', count: count, targetTweetId: tweetId };
- if (cursor) body.cursor = cursor;
- var result = await apiFetch(body);
- stampCooldown(source);
- var items = Array.isArray(result.items) ? result.items : [result.items];
- var nodeType = expandTool === 'tweet_replies_extractor' ? 'reply' : 'retweeter';
+ var result = await apiFetch(body);
+ stampCooldown(sources);
+ var items = Array.isArray(result.items) ? result.items : [result.items];
- var added = addNodes(items, nodeType, parentId);
+ var added = addNodes(items, cfg.nodeType, parentId, cfg.edgeLabel);
runLayout(false, false);
updateNodeCount();
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
- selectedNode.data(CURSOR_KEY[expandTool], result.nextCursor || null);
- selectedNode.data(EXHAUSTED_KEY[expandTool], !result.nextCursor);
+ selectedNode.data(cfg.cursorKey, result.nextCursor || null);
+ selectedNode.data(cfg.exhaustedKey, !result.nextCursor);
showPanel(selectedNode.data()); // refresh button labels/disabled state (e.g. "all loaded")
} catch (e) {
if (e.retryAfter) {
- // Keep both buttons disabled for the cooldown window — armExpandCountdown
- // re-enables them itself once it elapses, so don't touch them here.
- armExpandCountdown(e.retryAfter * 1000);
+ // Keep the button disabled for the cooldown window — armCooldownButtons
+ // re-enables it itself once it elapses, so don't touch it here.
+ armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Expand ' + cfg.label + ' again');
} else {
setStatus('Error: ' + e.message, true);
- document.getElementById('btnExpandReplies').disabled = false;
- document.getElementById('btnExpandRetweets').disabled = false;
+ btn.disabled = false;
}
}
}
-function armExpandCountdown(msRemaining) {
+// ── View a tweet/reply node's author as its own node ───────────────────────
+// A reply, retweet, or search-result tweet already carries its author's
+// avatar/bio/account-age info (cookie_client.py's _tweet_to_dict embeds it
+// on every tweet) — this never hits the network, it just re-shapes that
+// already-fetched data into a standalone user node with the same field
+// names cookie_client.py's _user_to_dict itself produces, so the node
+// renders identically to a real follower/following result. The synthesized
+// node's id is the author's own user_id, so an author who's already on
+// canvas some other way (e.g. also appears as a follower elsewhere) merges
+// into that same node instead of duplicating it — this is exactly what
+// makes a reply's author "look-up-able": select them afterward and Expand
+// Posts/Followers/Following works on them like any other user node.
+var AUTHOR_REMAP = { user_avatar: 'avatar', user_banner: 'banner', user_bio: 'description' };
+var AUTHOR_PASSTHROUGH = ['name', 'verified', 'is_blue_verified', 'user_location',
+ 'account_created', 'account_age', 'account_age_flag', 'account_age_precision',
+ 'source', 'fetched_at'];
+
+function authorFromRaw(raw) {
+ if (!raw || (!raw.user_id && !raw.user)) return null;
+ var out = { id: raw.user_id || null, screen_name: raw.user || null };
+ Object.keys(raw).forEach(function (k) {
+ if (AUTHOR_REMAP[k] && raw[k] != null) out[AUTHOR_REMAP[k]] = raw[k];
+ });
+ AUTHOR_PASSTHROUGH.forEach(function (k) {
+ if (raw[k] != null) out[k] = raw[k];
+ });
+ return out;
+}
+
+function viewAuthor() {
+ if (!selectedNode) return;
+ var raw = selectedNode.data('raw') || {};
+ var author = authorFromRaw(raw);
+ if (!author) { setStatus('No author info on this node', true); return; }
+
+ var added = addNodes([author], 'user', selectedNode.id(), 'authored by');
+ runLayout(false, false);
+ updateNodeCount();
+ setStatus(added ? 'Author profile added — select it to Expand Posts/Followers/Following'
+ : 'Author already on canvas — linked');
+}
+
+// ── Load more results for the root search node ─────────────────────────────
+// Mirrors expandNode() above — same click+cursor+cooldown mechanism, applied
+// to the search node itself instead of a tweet node, so any tool (not just
+// replies/retweets) can page past its first result set.
+async function expandSearch() {
+ if (!selectedNode) return;
+ var data = selectedNode.data();
+ if (data.type !== 'search' || !data.tool) return;
+
+ var sources = throttleSourceFor(data.tool, data.mode);
+ var btn = document.getElementById('btnLoadMoreSearch');
+ var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
+ if (remaining > 0) {
+ armCooldownButtons(remaining, [btn], 'Ready — click Load More again');
+ return;
+ }
+
+ var cursor = selectedNode.data('searchCursor') || null;
+ btn.disabled = true;
+ setStatus('Loading more…', false, true);
+
+ try {
+ var body = buildBody(data.tool, data.query, data.count, data.mode);
+ if (cursor) body.cursor = cursor;
+ var result = await apiFetch(body);
+ stampCooldown(sources);
+ var items = Array.isArray(result.items) ? result.items : [result.items];
+
+ var added = addNodes(items, function (item) { return resolveNodeType(data.tool, item); }, selectedNode.id());
+ runLayout(false, false);
+ updateNodeCount();
+ setStatus('Loaded more — ' + items.length + ' result(s) (' + added + ' new nodes)');
+
+ selectedNode.data('searchCursor', result.nextCursor || null);
+ selectedNode.data('searchExhausted', !result.nextCursor);
+ showPanel(selectedNode.data());
+ } catch (e) {
+ if (e.retryAfter) {
+ armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Load More again');
+ } else {
+ setStatus('Error: ' + e.message, true);
+ btn.disabled = false;
+ }
+ }
+}
+
+function armCooldownButtons(msRemaining, buttons, readyMessage) {
var secs = Math.max(1, Math.ceil(msRemaining / 1000));
setStatus('Rate-limit cooldown — retry in ' + secs + 's…', false, true);
- // Both Expand buttons share one underlying X-account clock, so both wait together.
- document.getElementById('btnExpandReplies').disabled = true;
- document.getElementById('btnExpandRetweets').disabled = true;
+ buttons.forEach(function (b) { b.disabled = true; });
if (cooldownTimer) clearTimeout(cooldownTimer);
cooldownTimer = setTimeout(function () {
cooldownTimer = null;
- document.getElementById('btnExpandReplies').disabled = false;
- document.getElementById('btnExpandRetweets').disabled = false;
- setStatus('Ready — click Expand again', false, false);
+ buttons.forEach(function (b) { b.disabled = false; });
+ setStatus(readyMessage, false, false);
}, msRemaining);
}
@@ -1050,17 +1423,31 @@ function esc(s) {
// LIST ARR PARAMS
var PRIORITY_KEYS = [
- 'source', 'account_age_flag', 'account_age', 'account_created',
+ 'source', 'content_type', 'account_age_flag', 'account_age', 'account_created',
'user', 'screen_name', 'name', 'text', 'post_title', 'post_text', 'created_at', 'fetched_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',
- 'archive_url', 'result_url', 'preview_image', 'display_link', 'iso_date', 'original', 'statuscode',
+ 'archive_url', 'result_url', 'preview_image', 'display_link', 'serp_title',
+ 'iso_date', 'original', 'statuscode',
];
-var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id']);
+// Same labels/reasoning as card_constants.js's CONTENT_TYPE_LABELS — kept as
+// a separate copy here since graph.html doesn't load that shared file (its
+// PRIORITY_KEYS/SKIP_KEYS are already their own independent copy, not the
+// index.html/archive.html shared ones).
+var CONTENT_TYPE_LABELS = {
+ tweet: 'Tweet',
+ profile: 'Profile page',
+ twitter_other: 'Other X/Twitter page',
+ other: 'External page (non-X)',
+};
+
+// avatar/banner URLs are rendered as an actual image in the panel header
+// below instead of a raw-link row.
+var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id', 'avatar', 'banner', 'user_avatar', 'user_banner']);
var SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback', 'Google CSE': 'src-cse' };
@@ -1090,6 +1477,44 @@ function extractMedia(item) {
}).filter(function (m) { return m.thumb; });
}
+// Same header treatment index.html's cards give a user — avatar, name,
+// handle, bio — but for the info panel, which every node type funnels
+// through (search root, tweet, reply, user, retweeter, ...).
+function buildPanelHeader(raw) {
+ var avatarRaw = raw.avatar || raw.user_avatar || '';
+ var avatar = isSafeImageUrl(avatarRaw) ? avatarRaw : '';
+ var name = raw.name || '';
+ var handle = raw.screen_name || raw.user || '';
+
+ if (!avatar && !name && !handle) return '';
+
+ var avatarHtml = avatar
+ ? ''
+ : (name || handle)
+ ? '
'
+ : '';
+
+ // A tweet/reply node's own text already shows a few rows down — leading
+ // with the author's full bio here would bury it the same way it did on
+ // index.html's cards. Bio only makes sense when the node itself IS a
+ // user (follower/retweeter), not a tweet someone happened to write.
+ var isTweetLike = raw.text !== undefined || raw.full_text !== undefined || raw.article_text !== undefined;
+ if (isTweetLike) {
+ return '
' + avatarHtml + identityHtml + '
';
+ }
+
+ var bio = raw.description || raw.user_bio || '';
+ var bioHtml = bio ? '
' + esc(bio) + '
' : '';
+
+ return '
' + avatarHtml + identityHtml + '
' + bioHtml;
+}
+
function showPanel(data) {
var raw = data.raw || {};
var type = data.type;
@@ -1124,6 +1549,12 @@ function showPanel(data) {
} else if (k === 'account_age_flag') {
var ageLabels = { new: 'New account', recent: 'Recent account', established: 'Established account' };
val = '' + esc(ageLabels[v] || String(v)) + '';
+ } else if (k === 'content_type') {
+ val = '' + esc(CONTENT_TYPE_LABELS[v] || String(v)) + '';
+ } else if (k === 'text' && raw.reply_to_mentions && raw.reply_to_mentions.length) {
+ var mentionBadge = '
';
+ val = mentionBadge + esc(stripLeadingMentions(String(v), raw.reply_to_mentions));
}
var label = esc(k.replace(/_/g, ' '));
@@ -1155,28 +1586,51 @@ function showPanel(data) {
rows += mediaParts.join('');
}
- document.getElementById('infoContent').innerHTML = rows;
+ document.getElementById('infoContent').innerHTML = buildPanelHeader(raw) + rows;
// Action buttons
- var isTweet = type === 'tweet' || type === 'reply';
- var isWayback = type === 'wayback';
- var tweetId = data.tweetId;
- var user = raw.user || raw.screen_name || '';
+ var isTweet = type === 'tweet' || type === 'reply';
+ var isUserNode = type === 'user' || type === 'retweeter';
+ var isWayback = type === 'wayback';
+ var tweetId = data.tweetId;
+ var user = raw.user || raw.screen_name || '';
var repliesBtn = document.getElementById('btnExpandReplies');
var retweetersBtn = document.getElementById('btnExpandRetweets');
+ var authorBtn = document.getElementById('btnViewAuthor');
+ var postsBtn = document.getElementById('btnExpandPosts');
+ var followersBtn = document.getElementById('btnExpandFollowers');
+ var followingBtn = document.getElementById('btnExpandFollowing');
+ var loadMoreBtn = document.getElementById('btnLoadMoreSearch');
+
repliesBtn.style.display = isTweet ? '' : 'none';
retweetersBtn.style.display = isTweet ? '' : 'none';
+ // A synthesized "view author" pivot only makes sense when there's
+ // somewhere to pivot TO — a tweet/reply always embeds its author's
+ // user_id/screen_name (cookie_client.py's _tweet_to_dict), but a bare
+ // xquik/API-mode record might not have captured either.
+ authorBtn.style.display = (isTweet && (raw.user_id || raw.user)) ? '' : 'none';
+
+ postsBtn.style.display = isUserNode ? '' : 'none';
+ followersBtn.style.display = isUserNode ? '' : 'none';
+ followingBtn.style.display = isUserNode ? '' : 'none';
+
+ var isPaginableSearch = type === 'search' && ROOT_PAGINATED_TOOLS.indexOf(data.tool) !== -1;
+ loadMoreBtn.style.display = isPaginableSearch ? '' : 'none';
+ if (isPaginableSearch) {
+ loadMoreBtn.textContent = data.searchExhausted ? '⤓ Load More Results (all loaded)'
+ : data.searchCursor ? '⤓ Load More Results (more available)' : '⤓ Load More Results';
+ loadMoreBtn.disabled = !!data.searchExhausted;
+ }
+
if (isTweet) {
- // Label reflects whether this node's already been expanded and whether
- // more is available — neither twikit nor the Wayback CDX API expose a
- // total count, only presence/absence of a next page, so no item count.
- repliesBtn.textContent = data.repliesExhausted ? '↩ Expand Replies (all loaded)'
- : data.repliesCursor ? '↩ Expand Replies (more available)' : '↩ Expand Replies';
- repliesBtn.disabled = !!data.repliesExhausted;
- retweetersBtn.textContent = data.retweetersExhausted ? '↗ Expand Retweets (all loaded)'
- : data.retweetersCursor ? '↗ Expand Retweets (more available)' : '↗ Expand Retweets';
- retweetersBtn.disabled = !!data.retweetersExhausted;
+ updateExpandButtonLabel(repliesBtn, data, EXPAND_TOOLS.tweet_replies_extractor, raw);
+ updateExpandButtonLabel(retweetersBtn, data, EXPAND_TOOLS.tweet_retweeters_extractor, raw);
+ }
+ if (isUserNode) {
+ updateExpandButtonLabel(postsBtn, data, EXPAND_TOOLS.post_extractor, raw);
+ updateExpandButtonLabel(followersBtn, data, EXPAND_TOOLS.follower_explorer, raw);
+ updateExpandButtonLabel(followingBtn, data, EXPAND_TOOLS.following_explorer, raw);
}
var openBtn = document.getElementById('btnOpenTweet');
diff --git a/Script/SOCMINT-Twitter/templates/index.html b/Script/SOCMINT-Twitter/templates/index.html
index d1346df..0a460f9 100644
--- a/Script/SOCMINT-Twitter/templates/index.html
+++ b/Script/SOCMINT-Twitter/templates/index.html
@@ -39,7 +39,9 @@
align-items: center;
gap: 10px;
background: var(--surface);
- position: relative;
+ position: sticky;
+ top: 0;
+ z-index: 100;
}
header h1 { font-size: 14px; font-weight: 600; letter-spacing: 0.02em; }
header .sep { color: var(--border); }
@@ -104,6 +106,27 @@
z-index: 251;
transition: all 0.15s;
}
+ .back-to-top {
+ position: fixed;
+ bottom: 14px;
+ right: 14px;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: #FFFFFF;
+ font-size: 17px;
+ cursor: pointer;
+ z-index: 251;
+ box-shadow: 0 4px 14px rgba(0,0,0,0.4);
+ transition: all 0.15s;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ }
+ .back-to-top:hover { border-color: var(--accent); }
+ .back-to-top.visible { display: flex; }
.help-toggle:hover { color: var(--text); border-color: var(--accent); }
.help-popup {
position: fixed;
@@ -290,6 +313,23 @@
.age-badge.age-recent { color: var(--warn); border-color: #78350f; background: #1c0e02; }
.age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }
+ /* What kind of X/Twitter page a Google CSE / Wayback result actually is —
+ a keyword match linking to x.com/someone reads as "just a profile" with
+ no way to tell a tweet permalink apart from a bare profile page or an
+ unrelated page otherwise. */
+ .content-type-badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 9px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ }
+ .content-type-badge.ct-tweet { color: var(--success); border-color: #14532d; background: #052011; }
+ .content-type-badge.ct-profile { color: #a78bfa; border-color: #4c3a8f; background: var(--cookie-bg); }
+ .content-type-badge.ct-twitter_other { color: var(--warn); border-color: #78350f; background: #1c0e02; }
+ .content-type-badge.ct-other { color: var(--muted); border-color: var(--border); background: var(--bg); }
+
.search-bar {
flex: 1;
min-width: 160px;
@@ -322,38 +362,6 @@
}
.btn-download:hover { color: var(--text); border-color: var(--accent); }
- /* Auto Archive toggle */
- .archive-toggle {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--border);
- cursor: pointer;
- user-select: none;
- }
- .toggle-track {
- width: 32px;
- height: 18px;
- border-radius: 9px;
- background: var(--border);
- position: relative;
- transition: background 0.2s;
- flex-shrink: 0;
- }
- .toggle-track.on { background: var(--accent); }
- .toggle-knob {
- position: absolute;
- top: 2px; left: 2px;
- width: 14px; height: 14px;
- border-radius: 50%;
- background: #fff;
- transition: left 0.2s;
- }
- .toggle-track.on .toggle-knob { left: 16px; }
- .toggle-label { font-size: 12px; color: var(--muted); }
-
/* Archive progress bar */
.archive-bar {
margin-top: 8px;
@@ -411,9 +419,91 @@
border-radius: 8px;
padding: 12px 14px;
transition: border-color 0.15s;
+ overflow: hidden;
}
.card:hover { border-color: #3a3d50; }
+ /* Profile header — cover photo, avatar, name/handle, bio. Only rendered
+ when the item actually carries any of that (a tweet's embedded author,
+ a bare user/follower/retweeter record); everything else keeps the plain
+ row list it always had. */
+ .card-header { margin-bottom: 10px; }
+ .card-header.has-banner {
+ margin: -12px -14px 10px;
+ }
+ .card-banner {
+ height: 64px;
+ background-size: cover;
+ background-position: center;
+ background-color: var(--bg);
+ }
+ .card-header-row {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+ }
+ .card-header.has-banner .card-header-row {
+ padding: 0 14px;
+ margin-top: -26px;
+ }
+ .card-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--surface);
+ background: var(--bg);
+ flex-shrink: 0;
+ }
+ .card-avatar-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--muted);
+ }
+ .card-identity { min-width: 0; padding-bottom: 3px; }
+ .card-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .card-handle { font-size: 12px; color: var(--muted); }
+ .card-bio {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--text);
+ opacity: 0.85;
+ line-height: 1.45;
+ }
+ .card-header.has-banner .card-bio { padding: 0 14px; }
+
+ /* Compact byline — tweets/replies (already lead with their own text a few
+ rows down) get just this instead of the full banner+bio header above,
+ which is reserved for records that ARE a user rather than a tweet
+ someone wrote. */
+ .card-byline {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+ .card-byline .card-avatar { width: 26px; height: 26px; border-width: 1px; }
+ .card-byline .card-avatar-fallback { font-size: 11px; }
+ .card-byline .card-identity {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding-bottom: 0;
+ min-width: 0;
+ }
+ .card-byline .card-name { font-size: 13px; max-width: 55%; }
+ .card-byline .card-handle { flex-shrink: 0; }
+
.card-row {
display: flex;
gap: 10px;
@@ -514,6 +604,16 @@
.card-row.rt-origin .card-key { color: var(--accent); }
.card-row.rt-origin .card-val { color: #c7d2fe; }
+ /* "Replying to @x @y" — x.com trims these from the visible tweet body and
+ shows them as their own line instead; matches that here so the reply
+ text itself isn't misread as having been written with those mentions
+ inline. Underlying text value is untouched, this is display-only. */
+ .reply-context {
+ font-size: 11px;
+ color: var(--muted);
+ margin-bottom: 4px;
+ }
+
/* Drill-down links on reply / retweet counts */
.drill-link {
color: var(--accent);
@@ -524,6 +624,49 @@
}
.drill-link:hover { color: #818cf8; }
+ /* Inline nested-reply threads — expand a reply's own replies in place,
+ recursively, instead of only linking out to a new tab. */
+ .reply-expand-btn {
+ display: inline-flex;
+ align-items: center;
+ margin-top: 10px;
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--accent);
+ font-family: var(--font);
+ font-size: 11px;
+ font-weight: 500;
+ padding: 4px 10px;
+ border-radius: 5px;
+ cursor: pointer;
+ }
+ .reply-expand-btn:hover:not(:disabled) { border-color: var(--accent); }
+ .reply-expand-btn:disabled { opacity: 0.5; cursor: wait; }
+
+ .reply-thread {
+ margin: 10px 0 2px 16px;
+ padding-left: 14px;
+ border-left: 2px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .reply-thread.hidden { display: none; }
+
+ .reply-load-more-btn {
+ align-self: flex-start;
+ background: none;
+ border: none;
+ color: var(--accent);
+ font-family: var(--font);
+ font-size: 11px;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ padding: 2px 0;
+ }
+ .reply-load-more-btn:disabled { opacity: 0.5; cursor: wait; text-decoration: none; }
+
/* View toggle (Cards / Map) */
.view-toggle {
display: flex;
@@ -582,24 +725,31 @@
Jieyab89 SOCMINT X
|
- Retrieve data using Xquik API or Cookie
+ Retrieve Twitter Data With Many Data Source and Sentiment Analysis
+
Source
Twitter Cookie Live via your cookie session
Xquik API Live via the xquik API
Wayback Machine Archived snapshot
Google CSE Live web result via Google Custom Search
+
Content type — Google CSE / Wayback only
+
Tweet A specific tweet permalink
+
Profile page A bare account profile, not a specific post
+
Other X/Twitter page Some other X page
+
External page (non-X) A site off X entirely (Google CSE only)
Account age
New account Created < 30 days ago
Recent account Created < 1 year ago
@@ -622,6 +772,7 @@
@@ -705,6 +847,18 @@
popup.addEventListener('click', function(e) { e.stopPropagation(); });
}());
+// Back to top — the page (not an inner pane) is what actually scrolls here,
+// same as the load-more sentinel further down relies on.
+(function() {
+ var btn = document.getElementById('backToTop');
+ window.addEventListener('scroll', function() {
+ btn.classList.toggle('visible', window.scrollY > 400);
+ });
+ btn.addEventListener('click', function() {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+}());
+
const toolType = document.getElementById('toolType');
const dynFields = document.getElementById('dynamicFields');
const runBtn = document.getElementById('runBtn');
@@ -714,29 +868,31 @@ const searchInput = document.getElementById('searchInput');
const resultCount = document.getElementById('resultCount');
const toolHint = document.getElementById('toolHint');
const downloadBtn = document.getElementById('downloadBtn');
-const archiveToggle = document.getElementById('archiveToggle');
-const toggleTrack = document.getElementById('toggleTrack');
+const archiveBtn = document.getElementById('archiveBtn');
const archiveBar = document.getElementById('archiveBar');
const archivesSection= document.getElementById('archivesSection');
const archivesList = document.getElementById('archivesList');
const loadMoreSentinel = document.getElementById('loadMoreSentinel');
const loadMoreStatus = document.getElementById('loadMoreStatus');
-const checkpointBtn = document.getElementById('checkpointBtn');
let currentMode = 'api';
let currentData = null;
-let autoArchive = false;
let currentPayload = null; // last run payload (for archive queryInfo)
-let archivedId = null; // archive id for the current run, once auto-archived — lets later load-more pages update it in place instead of creating a new archive
+// Archive id for the CURRENT run's data — same model as graph.html's
+// graphArchivedId: null until the Archive button is clicked once (creates),
+// then every later click (after scrolling/expanding loads more) checkpoints
+// that same archive in place instead of creating a new one each time. Reset
+// on every new Run, same as graph.html's clearGraph() resets its own.
+let archivedId = null;
-// ── Scroll-triggered load-more (Cookie mode + Wayback only) ────────────────
-// Tools/modes the backend actually paginates — everything else (xquik/API
-// mode, multi-source search) just gets a single page, same as before.
+// ── Scroll-triggered load-more ──────────────────────────────────────────────
+// Tools/modes the backend actually paginates — everything else (plain xquik/API
+// mode calls, article_extractor) just gets a single page.
const PAGINATED_TOOLS = new Set([
- 'tweet_search_extractor', 'follower_explorer', 'post_extractor',
+ 'tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
'community_post_extractor', 'tweet_replies_extractor',
'tweet_retweeters_extractor', 'geo_post_extractor',
- 'wayback_archive_search',
+ 'wayback_archive_search', 'multi_source_search',
]);
const THROTTLE_SECONDS = 5;
@@ -745,15 +901,21 @@ let loadingMore = false;
let cooldownUntil = { cookie: 0, wayback: 0 }; // Date.now()-based timestamps
let cooldownTimer = null;
+// Returns the list of rate-limit clocks a request against this tool/mode
+// touches. multi_source_search fans out to both cookie and wayback
+// internally, so a load-more page for it has to wait out (and then restart)
+// both cooldowns at once, not just one.
function throttleSourceFor(tool, mode) {
- if (tool === 'wayback_archive_search') return 'wayback';
- if (mode === 'cookie' && PAGINATED_TOOLS.has(tool)) return 'cookie';
- return null;
+ if (tool === 'wayback_archive_search') return ['wayback'];
+ if (tool === 'multi_source_search') return ['cookie', 'wayback'];
+ if (mode === 'cookie' && PAGINATED_TOOLS.has(tool)) return ['cookie'];
+ return [];
}
const HINTS = {
tweet_search_extractor: 'Search for tweets by keyword',
follower_explorer: 'List followers of an account',
+ following_explorer: 'List accounts a user follows — cookie only',
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',
@@ -769,6 +931,7 @@ const COOKIE_ONLY = new Set([
'tweet_replies_extractor',
'tweet_retweeters_extractor',
'geo_post_extractor',
+ 'following_explorer',
]);
// These tools don't use the single Mode toggle: wayback needs no auth at all,
@@ -781,6 +944,7 @@ const NO_AUTH = new Set([
const HAS_COUNT = new Set([
'tweet_search_extractor',
'follower_explorer',
+ 'following_explorer',
'post_extractor',
'community_post_extractor',
'tweet_replies_extractor',
@@ -823,7 +987,7 @@ function renderFields() {
// Tool-specific input fields
if (t === 'tweet_search_extractor') {
html += field('searchQuery', 'Search query', 'bitcoin');
- } else if (t === 'follower_explorer') {
+ } else if (t === 'follower_explorer' || t === 'following_explorer') {
html += field('targetUsername', 'Username or User ID', 'elonmusk or 44196397');
} else if (t === 'article_extractor') {
html += field('targetTweetId', 'Tweet ID', '1234567890');
@@ -850,12 +1014,12 @@ function renderFields() {
const showCount = NO_AUTH.has(t) || currentMode === 'cookie';
const countLabels = {
wayback_archive_search: 'Max snapshots',
- multi_source_search: 'Result count per source (max 200)',
+ multi_source_search: 'Result count per source (max 2000)',
};
html += `
';
}
-function buildCard(item) {
+// Only a plain https URL (no quotes/angle-brackets/whitespace/parens) is
+// ever interpolated into the CSS url('...') below — background-image goes
+// through a second parsing pass CSS-side, so HTML-attribute escaping alone
+// isn't sufficient there the way it is for a plain . Rejecting
+// anything but a clean https URL up front closes that off rather than
+// trying to escape a value for two contexts (HTML attribute + CSS token)
+// wedged into one string.
+function isSafeImageUrl(u) {
+ return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
+}
+
+// Tapping "Reply" on X auto-prefixes the compose box with every account the
+// reply-chain already has tagged, and that prefix is genuinely part of the
+// reply's own raw text — but x.com's own UI never shows it inline, trimming
+// it into a separate "Replying to @x @y" line instead. `reply_to_mentions`
+// (from cookie_client.py's _leading_reply_mentions) lists exactly which
+// leading @mentions those are; this only touches how the text RENDERS here,
+// never item.text itself, so JSON download / archive still get the
+// complete raw string untouched. mentions.length is walked in order and
+// stops at the first one that doesn't match — a data mismatch just leaves
+// the rest of the prefix visible instead of risking mangled text.
+function stripLeadingMentions(text, mentions) {
+ if (!text || !mentions || !mentions.length) return text;
+ let rest = text;
+ for (const m of mentions) {
+ const re = new RegExp('^\\s*@' + String(m).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
+ const match = rest.match(re);
+ if (!match) break;
+ rest = rest.slice(match[0].length);
+ }
+ rest = rest.replace(/^\s+/, '');
+ return rest || text;
+}
+
+// Returns { html, usedFields } instead of just a string — buildCard() below
+// needs to know exactly which raw keys actually ended up rendered in the
+// header so it can drop only THOSE from the generic row list. A static
+// "always hide these field names" list doesn't work here: CSE/Wayback
+// records also have a `description` field (Google's own snippet, renamed
+// from serp_snippet) that this header never touches (no avatar/name/handle
+// on those records, so it returns empty) — hiding it unconditionally would
+// have silently deleted the one thing the user asked to see more clearly.
+function buildCardHeader(item) {
+ const avatarRaw = item.avatar || item.user_avatar || '';
+ const avatar = isSafeImageUrl(avatarRaw) ? avatarRaw : '';
+ const name = item.name || '';
+ const handle = item.screen_name || item.user || item.username || '';
+
+ if (!avatar && !name && !handle) return { html: '', usedFields: [] };
+
+ const usedFields = [];
+ if (avatar) usedFields.push(item.avatar ? 'avatar' : 'user_avatar');
+ if (name) usedFields.push('name');
+ if (handle) usedFields.push(item.screen_name ? 'screen_name' : item.user ? 'user' : 'username');
+
+ const avatarHtml = avatar
+ ? ``
+ : (name || handle)
+ ? `
`
+ : '';
+
+ // A tweet or reply already leads with its own text a few rows down — a
+ // full cover-photo-plus-bio header buries that under the *author's*
+ // profile instead of the actual reply content (this is what made a reply
+ // thread look like it was rendering "just the user"). Those get a small
+ // inline byline only; the full profile-card treatment (banner + bio) is
+ // reserved for records that ARE a user — follower/retweeter results, not
+ // tweets a user happened to write.
+ const isTweetLike = item.text !== undefined || item.full_text !== undefined || item.article_text !== undefined;
+ if (isTweetLike) {
+ return { html: `
`;
}
const tweetId = item.id ? String(item.id) : null;
const entries = Object.entries(item);
+ const { html: header, usedFields: headerFields } = buildCardHeader(item);
// Order follows PRIORITY's own sequence (not object insertion order), so
// field position is deterministic and consistent across every tool/record
// shape. No cap on row count — this is an OSINT/analysis tool, so more
@@ -989,8 +1251,8 @@ function buildCard(item) {
// anchors once enough other fields — account age, verification, etc. —
// were present on a record).
const priKeys = new Set(entries.map(([k]) => k).filter(k => PRIORITY.includes(k)));
- const pri = PRIORITY.filter(k => priKeys.has(k)).map(k => [k, item[k]]);
- const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k));
+ const pri = PRIORITY.filter(k => priKeys.has(k) && !headerFields.includes(k)).map(k => [k, item[k]]);
+ const rest = entries.filter(([k]) => !PRIORITY.includes(k) && !SKIP.includes(k) && !headerFields.includes(k));
const rows = [...pri, ...rest].map(([k, v]) => {
let display;
// Drillable check runs FIRST — reply_count / retweet_count always get a link
@@ -1009,6 +1271,12 @@ function buildCard(item) {
display = `${esc(String(v))}`;
} else if (k === 'account_age_flag' && v) {
display = `${esc(AGE_LABELS[v] || v)}`;
+ } else if (k === 'content_type' && v) {
+ display = `${esc(CONTENT_TYPE_LABELS[v] || String(v))}`;
+ } else if (k === 'text' && item.reply_to_mentions && item.reply_to_mentions.length) {
+ const badge = `
↩ Replying to ${item.reply_to_mentions.map(m => '@' + esc(m)).join(', ')}
`;
+ const clean = stripLeadingMentions(String(v), item.reply_to_mentions);
+ display = `${badge}${esc(clean)}`;
} else if (v === null || v === undefined) {
display = `—`;
} else if (typeof v === 'object') {
@@ -1022,7 +1290,24 @@ function buildCard(item) {
return `
${esc(k)}
${display}
`;
}).join('');
const media = extractMedia(item);
- return `
${rows}${renderMedia(media)}
`;
+
+ // Any tweet-shaped item with replies of its own can be expanded in place —
+ // same tweet_replies_extractor call graph.html's node-expand uses, applies
+ // uniformly across every tool's cards (search results, timeline, replies
+ // themselves, ...) since it only keys off id + reply_count, not the tool
+ // that produced the card. A reply rendered here recursively gets its own
+ // Expand button too, since it's built by this same function.
+ const replyCount = Number(item.reply_count);
+ const canExpandReplies = tweetId && Number.isFinite(replyCount) && replyCount > 0;
+ const expandBtn = canExpandReplies
+ ? ``
+ : '';
+
+ const cardCls = nested ? 'card reply-card' : 'card';
+ const card = `
${header}${rows}${renderMedia(media)}${expandBtn}
`;
+ if (!canExpandReplies) return card;
+
+ return `
${card}
`;
}
// ── Load more (scroll-triggered pagination) ─────────────────────────────────
@@ -1037,18 +1322,167 @@ function appendCards(newItems, query = '') {
if (grid && toShow.length) {
grid.insertAdjacentHTML('beforeend', toShow.map(buildCard).join(''));
}
- const totalShown = resultBox.querySelectorAll('.card').length;
+ // Excludes .reply-card — nested replies rendered inline via expand threads
+ // aren't part of "the results," same reasoning renderCards' own count
+ // (items.length, never a DOM query) already never counted them either.
+ const totalShown = resultBox.querySelectorAll('.card:not(.reply-card)').length;
resultCount.textContent = totalShown + (q ? ' found' : ' results');
}
+// ── Inline nested-reply threads ─────────────────────────────────────────────
+// Delegated once at the container level — buildCard() renders replies
+// recursively via itself, so an expand button can appear at any nesting
+// depth; delegation catches all of them without per-card listener wiring.
+resultBox.addEventListener('click', (e) => {
+ const expandBtn = e.target.closest('.reply-expand-btn');
+ if (expandBtn) { toggleReplyThread(expandBtn); return; }
+ const moreBtn = e.target.closest('.reply-load-more-btn');
+ if (moreBtn) { loadMoreReplies(moreBtn); }
+});
+
+function replyBtnLabel(count) {
+ return `↩ Expand ${count} repl${count === 1 ? 'y' : 'ies'}`;
+}
+
+async function toggleReplyThread(btn) {
+ const wrap = btn.closest('.card-thread-wrap');
+ const thread = wrap.querySelector(':scope > .reply-thread');
+
+ // Already fetched once — just show/hide, no new request.
+ if (thread.dataset.loaded === '1') {
+ const nowHidden = thread.classList.toggle('hidden');
+ btn.textContent = nowHidden ? replyBtnLabel(Number(btn.dataset.count)) : '▲ Hide replies';
+ return;
+ }
+
+ const tweetId = btn.dataset.tweetId;
+ const remaining = cooldownUntil.cookie - Date.now();
+ if (remaining > 0) {
+ armReplyCountdown(btn, remaining, () => replyBtnLabel(Number(btn.dataset.count)));
+ return;
+ }
+
+ btn.disabled = true;
+ btn.textContent = 'Loading…';
+
+ try {
+ const res = await fetch('/api/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ toolType: 'tweet_replies_extractor', mode: 'cookie', count: 20, targetTweetId: tweetId }),
+ });
+ const json = await res.json();
+ stampCooldown(['cookie']);
+
+ if (!json.ok) {
+ if (res.status === 429 && json.retryAfter) {
+ armReplyCountdown(btn, json.retryAfter * 1000, () => replyBtnLabel(Number(btn.dataset.count)));
+ return;
+ }
+ btn.disabled = false;
+ btn.textContent = 'Failed — retry';
+ return;
+ }
+
+ const items = Array.isArray(json.data) ? json.data : [json.data];
+ thread.innerHTML = items.map(it => buildCard(it, true)).join('');
+ thread.dataset.loaded = '1';
+ if (json.nextCursor) {
+ thread.dataset.cursor = json.nextCursor;
+ thread.insertAdjacentHTML('beforeend',
+ ``);
+ }
+ thread.classList.remove('hidden');
+ btn.disabled = !items.length;
+ btn.textContent = items.length ? '▲ Hide replies' : 'No replies found';
+
+ // Fold newly-discovered replies into the same dataset the JSON download
+ // and Archive button both read from — otherwise data pulled via inline
+ // expand would silently be excluded from both.
+ if (items.length) {
+ currentData = (Array.isArray(currentData) ? currentData : [currentData]).concat(items);
+ }
+ } catch (err) {
+ btn.disabled = false;
+ btn.textContent = 'Failed — retry';
+ }
+}
+
+async function loadMoreReplies(moreBtn) {
+ const thread = moreBtn.closest('.reply-thread');
+ const tweetId = moreBtn.dataset.tweetId;
+ const cursor = thread.dataset.cursor;
+ if (!cursor) { moreBtn.remove(); return; }
+
+ const remaining = cooldownUntil.cookie - Date.now();
+ if (remaining > 0) {
+ armReplyCountdown(moreBtn, remaining, () => 'Load more replies…');
+ return;
+ }
+
+ moreBtn.disabled = true;
+ moreBtn.textContent = 'Loading…';
+
+ try {
+ const res = await fetch('/api/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ toolType: 'tweet_replies_extractor', mode: 'cookie', count: 20, targetTweetId: tweetId, cursor }),
+ });
+ const json = await res.json();
+ stampCooldown(['cookie']);
+
+ if (!json.ok) {
+ if (res.status === 429 && json.retryAfter) {
+ armReplyCountdown(moreBtn, json.retryAfter * 1000, () => 'Load more replies…');
+ return;
+ }
+ moreBtn.disabled = false;
+ moreBtn.textContent = 'Failed — retry';
+ return;
+ }
+
+ const items = Array.isArray(json.data) ? json.data : [json.data];
+ moreBtn.insertAdjacentHTML('beforebegin', items.map(it => buildCard(it, true)).join(''));
+ if (json.nextCursor) {
+ thread.dataset.cursor = json.nextCursor;
+ moreBtn.disabled = false;
+ moreBtn.textContent = 'Load more replies…';
+ } else {
+ moreBtn.remove();
+ }
+
+ if (items.length) {
+ currentData = (Array.isArray(currentData) ? currentData : [currentData]).concat(items);
+ }
+ } catch (err) {
+ moreBtn.disabled = false;
+ moreBtn.textContent = 'Failed — retry';
+ }
+}
+
+// Shared by both reply-expand entry points above — mirrors armCountdown()
+// but scoped to a single button instead of the page-wide loadMoreStatus bar,
+// since several threads can be mid-cooldown independently of each other.
+function armReplyCountdown(btn, msRemaining, resetLabel) {
+ const secs = Math.max(1, Math.ceil(msRemaining / 1000));
+ btn.disabled = true;
+ btn.textContent = `Wait ${secs}s…`;
+ setTimeout(() => {
+ btn.disabled = false;
+ btn.textContent = resetLabel();
+ }, msRemaining);
+}
+
function resetPagination() {
- nextCursor = null;
- loadingMore = false;
- archivedId = null;
+ nextCursor = null;
+ loadingMore = false;
+ archivedId = null;
+ archiveBtn.textContent = 'Archive';
+ archiveBar.style.display = 'none';
if (cooldownTimer) { clearTimeout(cooldownTimer); cooldownTimer = null; }
loadMoreStatus.classList.remove('visible');
loadMoreStatus.textContent = '';
- checkpointBtn.style.display = 'none';
}
function buildQueryInfo() {
@@ -1059,39 +1493,63 @@ function buildQueryInfo() {
return queryInfo;
}
-function stampCooldown(source) {
- if (!source) return;
- cooldownUntil[source] = Date.now() + THROTTLE_SECONDS * 1000;
+function stampCooldown(sources) {
+ (sources || []).forEach(source => {
+ cooldownUntil[source] = Date.now() + THROTTLE_SECONDS * 1000;
+ });
+}
+
+function currentItemCount() {
+ return Array.isArray(currentData) ? currentData.length : (currentData ? 1 : 0);
+}
+
+// count is "how many items I want," not just a per-page size hint — X's own
+// API caps how many it actually returns per page regardless of what's
+// requested (count=200 still only returns ~20 on the first page), so
+// reaching the requested count used to mean manually scrolling the sentinel
+// into view over and over. This chains load-more calls automatically (still
+// gated by the exact same per-source throttle everything else respects)
+// until either the requested count is reached or the source runs dry —
+// scrolling further past that point still works via the IntersectionObserver
+// below, for open-ended browsing beyond the original ask.
+function maybeAutoContinue() {
+ if (!currentPayload || !currentPayload.count) return;
+ if (currentItemCount() >= currentPayload.count) return;
+ maybeLoadMore();
+}
+
+function progressSuffix() {
+ return (currentPayload && currentPayload.count) ? ` (${currentItemCount()}/${currentPayload.count})` : '';
}
function maybeLoadMore() {
if (!nextCursor || loadingMore || !currentPayload) return;
- const source = throttleSourceFor(currentPayload.toolType, currentPayload.mode);
- if (!source) return; // shouldn't happen if nextCursor is set, but be safe
+ const sources = throttleSourceFor(currentPayload.toolType, currentPayload.mode);
+ if (!sources.length) return; // shouldn't happen if nextCursor is set, but be safe
- const remaining = cooldownUntil[source] - Date.now();
+ const remaining = Math.max(0, ...sources.map(s => cooldownUntil[s] - Date.now()));
if (remaining > 0) {
armCountdown(remaining);
return;
}
- doLoadMore(source);
+ doLoadMore(sources);
}
function armCountdown(msRemaining) {
if (cooldownTimer) return; // already counting down
const secs = Math.max(1, Math.ceil(msRemaining / 1000));
loadMoreStatus.classList.add('visible');
- loadMoreStatus.innerHTML = `Loading more in ${secs}s…`;
+ loadMoreStatus.innerHTML = `Loading more in ${secs}s…${progressSuffix()}`;
cooldownTimer = setTimeout(() => {
cooldownTimer = null;
maybeLoadMore(); // re-check: fires the load once the cooldown has cleared
}, msRemaining);
}
-async function doLoadMore(source) {
+async function doLoadMore(sources) {
loadingMore = true;
loadMoreStatus.classList.add('visible');
- loadMoreStatus.innerHTML = `Loading more…`;
+ loadMoreStatus.innerHTML = `Loading more…${progressSuffix()}`;
try {
const res = await fetch('/api/run', {
@@ -1100,7 +1558,7 @@ async function doLoadMore(source) {
body: JSON.stringify({ ...currentPayload, cursor: nextCursor }),
});
const json = await res.json();
- stampCooldown(source);
+ stampCooldown(sources);
if (!json.ok) {
if (res.status === 429 && json.retryAfter) {
@@ -1118,23 +1576,11 @@ async function doLoadMore(source) {
appendCards(newItems, searchInput.value);
nextCursor = json.nextCursor || null;
loadMoreStatus.classList.remove('visible');
-
- // Auto-archive only covered whatever was loaded at the initial Run — new
- // pages from scroll need their own save. Once we've reached the end
- // (no more pages), save automatically; otherwise surface a manual
- // checkpoint button so the user can save without waiting to hit the end.
- if (autoArchive && archivedId) {
- if (!nextCursor) {
- checkpointBtn.style.display = 'none';
- triggerArchive(currentData, currentPayload.toolType, buildQueryInfo(), archivedId);
- } else {
- checkpointBtn.style.display = '';
- }
- }
} catch (e) {
loadMoreStatus.textContent = `Load more failed: ${e}`;
} finally {
loadingMore = false;
+ maybeAutoContinue();
}
}
@@ -1157,7 +1603,7 @@ runBtn.addEventListener('click', async () => {
const payload = { toolType: t, mode: currentMode, count };
if (t === 'tweet_search_extractor') payload.searchQuery = val('searchQuery');
- if (t === 'follower_explorer' || t === 'post_extractor') payload.targetUsername = val('targetUsername');
+ if (t === 'follower_explorer' || t === 'following_explorer' || t === 'post_extractor') payload.targetUsername = val('targetUsername');
if (t === 'community_post_extractor') payload.targetCommunityId = val('targetCommunityId');
if (t === 'article_extractor' ||
t === 'tweet_replies_extractor' ||
@@ -1183,6 +1629,7 @@ runBtn.addEventListener('click', async () => {
statusBadge.className = 'badge running';
searchInput.style.display = 'none';
downloadBtn.style.display = 'none';
+ archiveBtn.style.display = 'none';
resultCount.textContent = '';
currentData = null;
resultBox.innerHTML = '
Fetching data…
';
@@ -1207,6 +1654,7 @@ runBtn.addEventListener('click', async () => {
nextCursor = json.nextCursor || null;
const isMany = Array.isArray(json.data) && json.data.length > 1;
downloadBtn.style.display = '';
+ archiveBtn.style.display = '';
// Show map toggle only for geo results; reset to card view on each run
const isGeo = (t === 'geo_post_extractor');
@@ -1223,12 +1671,7 @@ runBtn.addEventListener('click', async () => {
searchInput.style.display = (isMany && !isGeo) ? '' : 'none';
renderCards(currentData);
-
- if (autoArchive) {
- triggerArchive(json.data, t, buildQueryInfo());
- } else {
- archiveBar.style.display = 'none';
- }
+ maybeAutoContinue();
} else {
statusBadge.textContent = 'error';
statusBadge.className = 'badge err';
@@ -1244,40 +1687,49 @@ runBtn.addEventListener('click', async () => {
}
});
-// ── Auto Archive ─────────────────────────────────────────────────────────────
+// ── Archive ───────────────────────────────────────────────────────────────────
+// Manual, on demand — same model as graph.html's "Archive All" button rather
+// than the old auto-checkpoint-on-every-scroll system (which had a real race:
+// a fast scroll landing between the archive-creation call setting its
+// in-flight flag and that call's fetch actually resolving could silently
+// drop that checkpoint for good). Click Archive whenever you want to save
+// exactly what's currently loaded; click it again after scrolling/expanding
+// loads more and it checkpoints the SAME archive in place instead of creating a
+// new one each time — identical behavior to graph.html's graphArchivedId.
-archiveToggle.addEventListener('click', () => {
- autoArchive = !autoArchive;
- toggleTrack.classList.toggle('on', autoArchive);
- if (autoArchive) loadRecentArchives();
-});
+archiveBtn.addEventListener('click', archiveResults);
+
+async function archiveResults() {
+ if (!currentData || !currentPayload) return;
-async function triggerArchive(data, toolType, queryInfo, existingId) {
archiveBar.style.display = '';
- const label = existingId ? 'Saving checkpoint…' : 'Archiving…';
+ const label = archivedId ? 'Saving checkpoint…' : 'Archiving…';
archiveBar.innerHTML = `${label}
`;
- const body = { data, toolType, queryInfo };
- if (existingId) body.archiveId = existingId;
+ try {
+ const body = {
+ toolType: currentPayload.toolType,
+ data: Array.isArray(currentData) ? currentData : [currentData],
+ queryInfo: buildQueryInfo(),
+ };
+ if (archivedId) body.archiveId = archivedId;
- const res = await fetch('/api/archive', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- const json = await res.json();
- if (!json.ok) { archiveBar.innerHTML = `Archive failed: ${esc(json.error)}`; return; }
+ const res = await fetch('/api/archive', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const json = await res.json();
+ if (!json.ok) throw new Error(json.error);
- archivedId = json.archiveId;
- pollArchive(json.archiveId);
+ archivedId = json.archiveId;
+ archiveBtn.textContent = 'Update Archive';
+ pollArchive(json.archiveId);
+ } catch (e) {
+ archiveBar.innerHTML = `Archive failed: ${esc(e.message)}`;
+ }
}
-checkpointBtn.addEventListener('click', () => {
- if (!archivedId || !currentPayload) return;
- checkpointBtn.style.display = 'none';
- triggerArchive(currentData, currentPayload.toolType, buildQueryInfo(), archivedId);
-});
-
function pollArchive(archiveId) {
const interval = setInterval(async () => {
const res = await fetch(`/api/archive/${archiveId}/status`);
@@ -1519,13 +1971,6 @@ async function updateMap() {
const idInput = document.getElementById('targetTweetId');
if (idInput) idInput.value = id;
- // Restore auto-archive state if the parent tab had it on
- if (p.get('archive') === '1') {
- autoArchive = true;
- toggleTrack.classList.add('on');
- loadRecentArchives();
- }
-
// Kick off the run automatically
runBtn.click();
diff --git a/Script/SOCMINT-Twitter/wayback_client.py b/Script/SOCMINT-Twitter/wayback_client.py
index 1c65972..96c347e 100644
--- a/Script/SOCMINT-Twitter/wayback_client.py
+++ b/Script/SOCMINT-Twitter/wayback_client.py
@@ -35,6 +35,34 @@ class WaybackError(Exception):
_DATE8_RE = re.compile(r"^\d{8}$")
_SAFE_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
_TWEET_ID_RE = re.compile(r"/status/(\d+)")
+# Wayback crawled every historical subdomain variant (mobile./m./www./bare),
+# unlike a live search result which is always the canonical domain — hence
+# the looser (?:[\w-]+\.)? host prefix here vs google_cse_client's (?:www\.)?.
+_X_HOST_RE = re.compile(r"^https?://(?:[\w-]+\.)?(?:x|twitter)\.com(?:/|$)", re.IGNORECASE)
+_PROFILE_RE = re.compile(r"^https?://(?:[\w-]+\.)?(?:x|twitter)\.com/([^/?#]+)/?(?:\?.*)?$", re.IGNORECASE)
+_NON_PROFILE_PATHS = {
+ "home", "explore", "notifications", "messages", "i", "search", "settings",
+ "compose", "login", "logout", "signup", "tos", "privacy", "about", "hashtag",
+}
+
+
+def _classify_url(url: str) -> str:
+ """Same reasoning as google_cse_client's copy of this — an archived
+ snapshot of x.com/someone reads as "a Twitter profile" whether it's
+ actually a tweet permalink, a bare profile page, or some other X page
+ entirely. Purely a label derived from the URL's own shape; never touches
+ original/archive_url themselves. Returns
+ 'tweet' | 'profile' | 'twitter_other' | 'other'."""
+ if not url:
+ return "other"
+ if _TWEET_ID_RE.search(url):
+ return "tweet"
+ if not _X_HOST_RE.match(url):
+ return "other"
+ m = _PROFILE_RE.match(url)
+ if m and m.group(1).lower() not in _NON_PROFILE_PATHS:
+ return "profile"
+ return "twitter_other"
def _validate_date(label: str, value: str) -> None:
@@ -124,13 +152,14 @@ def _row_to_record(row: dict) -> dict:
if len(ts) >= 14:
iso_date = f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[8:10]}:{ts[10:12]}:{ts[12:14]}"
record = {
- "timestamp": ts, # internal only — stripped before returning to caller
- "iso_date": iso_date, # when Wayback captured this snapshot
- "original": original,
- "statuscode": row.get("statuscode"),
- "mimetype": row.get("mimetype"),
- "length": row.get("length"),
- "archive_url": f"https://web.archive.org/web/{ts}/{original}" if ts and original else None,
+ "timestamp": ts, # internal only — stripped before returning to caller
+ "iso_date": iso_date, # when Wayback captured this snapshot
+ "original": original,
+ "content_type": _classify_url(original),
+ "statuscode": row.get("statuscode"),
+ "mimetype": row.get("mimetype"),
+ "length": row.get("length"),
+ "archive_url": f"https://web.archive.org/web/{ts}/{original}" if ts and original else None,
}
created_at = _tweet_created_at(original)
if created_at:
diff --git a/Web-Based/gen-node/node-generate.py b/Web-Based/gen-node/node-generate.py
index add912a..e86f9a5 100644
--- a/Web-Based/gen-node/node-generate.py
+++ b/Web-Based/gen-node/node-generate.py
@@ -9,6 +9,7 @@ urls = {
"readme": "https://raw.githubusercontent.com/Jieyab89/OSINT-Cheat-sheet/refs/heads/main/README.md",
"wiki": "https://github.com/Jieyab89/OSINT-Cheat-sheet/wiki",
"articles": "https://raw.githubusercontent.com/Jieyab89/OSINT-Cheat-sheet/main/awesome-article.md"
+ #"scripts": "https://github.com/Jieyab89/OSINT-Cheat-sheet/blob/main/Script/README.md" soon
}
headers = {