diff --git a/.gitignore b/.gitignore
index 68597ea..76cf9aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ __pycache__/
# Research Reports (DO NOT UPLOAD)
reports/
+
+# macOS Finder metadata
+.DS_Store
diff --git a/README.md b/README.md
index 6c6c9ba..646930c 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,31 @@
- π **Batch Processing:** Scan lists of targets via TXT file.
- π‘ **Automatic API Setup:** Interactive onboarding on the first run.
+---
+
+## π Account creation date, no API key needed
+
+Want to know when a TikTok account was created without setting up an API key? `tiktok_created.py` reads the `createTime` that TikTok embeds in the public profile page, so a username is all you need.
+
+```bash
+python3 tiktok_created.py charlidamelio
+python3 tiktok_created.py @nasa https://www.tiktok.com/@zachking
+```
+
+You get the account creation date plus the basics (followers, likes, bio, verified, private). It also decodes a video URL or ID to its upload time, using the snowflake timestamp inside the id (`id >> 32`). Reports save to `reports/` as JSON and TXT, the same as the main tool.
+
+### Start the app (interactive UI)
+
+The UI shows a TikTok style banner and a prompt where you type usernames one after another.
+
+- **macOS:** double click `TokIntel.app` (or `TokIntel.command`)
+- **Windows:** double click `start.bat`
+- **Any terminal (macOS or Linux):** run `./start.sh`
+
+The launcher creates its own virtual environment and installs `requests`, `colorama`, and `rich` on first run, so there is nothing to set up by hand.
+
+_This no-key lookup mode is an addition to TokIntel by Victor Bancayan (Hack Underway), contributed by [@Thyfwx](https://github.com/Thyfwx)._
+
## π Prerequisites
- Python 3.8+
diff --git a/TokIntel.app/Contents/Info.plist b/TokIntel.app/Contents/Info.plist
new file mode 100644
index 0000000..add072a
--- /dev/null
+++ b/TokIntel.app/Contents/Info.plist
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleName
+ TokIntel
+ CFBundleDisplayName
+ TokIntel
+ CFBundleIdentifier
+ com.tokintel.launcher
+ CFBundleVersion
+ 1.0
+ CFBundleShortVersionString
+ 1.0
+ CFBundleExecutable
+ TokIntel
+ CFBundlePackageType
+ APPL
+ LSMinimumSystemVersion
+ 10.13
+
+
diff --git a/TokIntel.app/Contents/MacOS/TokIntel b/TokIntel.app/Contents/MacOS/TokIntel
new file mode 100755
index 0000000..ae2226e
--- /dev/null
+++ b/TokIntel.app/Contents/MacOS/TokIntel
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Double-clicking TokIntel.app runs this. It finds the repo (the app bundle
+# lives at /TokIntel.app/Contents/MacOS/TokIntel) and opens start.sh in
+# Terminal, which launches the UI. App bundles always launch, never open in an
+# editor, so this is the reliable double-click path on macOS.
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/../../.." && pwd)"
+open -a Terminal "$REPO/start.sh"
diff --git a/TokIntel.command b/TokIntel.command
new file mode 100755
index 0000000..632f088
--- /dev/null
+++ b/TokIntel.command
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Double-click this file in Finder (macOS) to launch TokIntel in Terminal.
+# It just hands off to start.sh next to it.
+DIR="$(cd "$(dirname "$0")" && pwd)"
+exec "$DIR/start.sh"
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..9f833f9
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,14 @@
+@echo off
+REM Windows double-click launcher for TokIntel.
+REM Sets up its own virtual environment and dependencies on first run.
+cd /d "%~dp0"
+
+if not exist "venv\Scripts\python.exe" (
+ echo First run - setting up a local Python environment...
+ python -m venv venv
+)
+
+venv\Scripts\python.exe -c "import importlib.util as u,sys;sys.exit(0 if all(u.find_spec(m) for m in ('requests','colorama','rich')) else 1)" 2>nul || venv\Scripts\python.exe -m pip install -q requests colorama rich
+
+venv\Scripts\python.exe tiktok_ui.py %*
+pause
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..76bdf04
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# TokIntel β TikTok account-creation lookup UI.
+# ./start.sh run from a terminal
+# TokIntel.command double-click in Finder (macOS)
+# Self-locating (works wherever the repo lives) and self-healing (sets up its
+# own venv + dependencies on first run).
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$DIR" || exit 1
+PY="$DIR/venv/bin/python"
+
+if [ ! -x "$PY" ]; then
+ echo "First run β setting up a local Python environmentβ¦"
+ python3 -m venv "$DIR/venv" || { echo "python3 is required."; exit 1; }
+ PY="$DIR/venv/bin/python"
+fi
+
+# Install dependencies only if any are missing.
+"$PY" - <<'CHECK' 2>/dev/null || "$PY" -m pip install -q requests colorama rich
+import importlib.util as u, sys
+sys.exit(0 if all(u.find_spec(m) for m in ("requests", "colorama", "rich")) else 1)
+CHECK
+
+exec "$PY" "$DIR/tiktok_ui.py" "$@"
diff --git a/tiktok_created.py b/tiktok_created.py
new file mode 100644
index 0000000..431aff9
--- /dev/null
+++ b/tiktok_created.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+"""
+tiktok_created.py: when was a TikTok account created? (no API key needed)
+
+TikTok's public profile page embeds the account's `createTime` (and a lot of
+other profile data) in a JSON blob. We read that directly, so there is no API
+key to set up. Works from a username, an @handle, or a profile URL.
+
+Bonus: a TikTok *video* URL/ID decodes to its upload time via the Snowflake
+timestamp baked into video IDs (id >> 32). (Note: many *user* IDs are NOT
+snowflakes, so we trust the embedded createTime for accounts, not id>>32.)
+
+Usage:
+ ./venv/bin/python tiktok_created.py --input charlidamelio
+ ./venv/bin/python tiktok_created.py --input https://www.tiktok.com/@nasa
+ ./venv/bin/python tiktok_created.py --input https://www.tiktok.com/@x/video/7076288989640055298
+ ./venv/bin/python tiktok_created.py --file usernames.txt
+
+Part of TokIntel (https://github.com/HackUnderway/TokIntel) by Victor Bancayan /
+Hack Underway. This account-lookup addition (no API key needed) was contributed
+by @Thyfwx.
+"""
+import argparse
+import json
+import os
+import re
+import random
+import string
+import time
+from datetime import datetime, UTC
+from urllib.parse import quote
+
+import requests
+
+try:
+ from colorama import Fore, init
+ init(autoreset=True)
+except Exception: # colorama is optional
+ class _Nope:
+ def __getattr__(self, _): return ""
+ Fore = _Nope()
+
+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
+TIMEOUT = 20
+DELAY = 1.5 # be polite between requests
+
+REHYDRATION_RE = re.compile(
+ r'id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)', re.S)
+
+
+# ------------------------------------------------------------------ helpers
+def fmt(ts):
+ try:
+ return datetime.fromtimestamp(int(ts), UTC).strftime('%Y-%m-%d %H:%M:%S UTC')
+ except Exception:
+ return None
+
+
+def classify(value):
+ """Return ('video', video_id) | ('user', username) | ('id', number)."""
+ v = value.strip()
+
+ vid = re.search(r'/video/(\d+)', v)
+ if vid:
+ return "video", vid.group(1)
+
+ handle = re.search(r'tiktok\.com/@([\w.\-]+)', v)
+ if handle:
+ return "user", handle.group(1)
+
+ if v.lstrip('@').replace('.', '').replace('_', '').replace('-', '').isalnum() and not v.isdigit():
+ return "user", v.lstrip('@')
+
+ if v.isdigit():
+ return "id", v
+
+ return "user", v.lstrip('@')
+
+
+def decode_snowflake(num):
+ """id >> 32 -> upload/creation unix seconds (only valid for 64-bit snowflakes)."""
+ n = int(num)
+ if n < (1 << 40): # too small to be a snowflake β would decode to ~1970
+ return None
+ return n >> 32
+
+
+# ------------------------------------------------------------------ fetchers
+def fetch_user(username, session):
+ # quote() leaves valid username characters (letters, digits, _ . - ~) as-is
+ # but percent-encodes anything unusual, so a crafted target can't alter the
+ # request structure. The host and scheme are already a fixed literal prefix.
+ url = f"https://www.tiktok.com/@{quote(username, safe='')}"
+ r = session.get(url, timeout=TIMEOUT)
+ m = REHYDRATION_RE.search(r.text)
+ if not m:
+ return {"error": f"no_data (http_{r.status_code}, {len(r.text)}B) β rate-limited/blocked or layout changed"}
+ try:
+ scope = json.loads(m.group(1)).get("__DEFAULT_SCOPE__", {})
+ except ValueError:
+ return {"error": "json_parse_failed"}
+
+ detail = scope.get("webapp.user-detail")
+ if not detail:
+ return {"error": "user-detail missing β profile not returned (blocked, or no such page)"}
+
+ status = detail.get("statusCode")
+ info = detail.get("userInfo", {})
+ user = info.get("user", {})
+ # statsV2 carries correct string counts; the legacy `stats` object stores
+ # 32-bit ints that overflow (negative likes) for accounts past ~2.1B, so
+ # prefer statsV2 and fall back to stats only if it's absent.
+ stats = info.get("statsV2") or info.get("stats") or {}
+ if not user:
+ return {"error": f"user not found (statusCode={status})"}
+
+ def as_int(v):
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return v
+
+ ct = user.get("createTime")
+ uid = user.get("id")
+ id_est = decode_snowflake(uid) if uid and str(uid).isdigit() else None
+
+ return {
+ "type": "account",
+ "username": user.get("uniqueId") or username,
+ "nickname": user.get("nickname"),
+ "account_created": fmt(ct) if ct else None,
+ "account_created_unix": ct,
+ "created_estimate_from_id": fmt(id_est) if id_est else None,
+ "user_id": uid,
+ "sec_uid": user.get("secUid"),
+ "verified": user.get("verified"),
+ "private": user.get("privateAccount"),
+ "bio": user.get("signature"),
+ "region": user.get("region"),
+ "followers": as_int(stats.get("followerCount")),
+ "following": as_int(stats.get("followingCount")),
+ "likes": as_int(stats.get("heartCount")),
+ "videos": as_int(stats.get("videoCount")),
+ }
+
+
+def decode_video(video_id):
+ ts = decode_snowflake(video_id)
+ return {
+ "type": "video",
+ "video_id": video_id,
+ "uploaded": fmt(ts) if ts else None,
+ "note": "This is the VIDEO upload time, not the account creation date.",
+ }
+
+
+def decode_id(num):
+ ts = decode_snowflake(num)
+ return {
+ "type": "raw_id",
+ "id": num,
+ "decoded": fmt(ts) if ts else None,
+ "note": ("Decoded from snowflake id>>32. Works for video IDs and "
+ "snowflake user IDs; small/legacy user IDs can't be decoded "
+ "this way β fetch the @username instead for createTime."),
+ }
+
+
+# ------------------------------------------------------------------ reports
+def reports_dir():
+ if not os.path.exists("reports"):
+ os.makedirs("reports")
+ return "reports"
+
+
+def save_reports(results, prefix):
+ rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ base = os.path.join(reports_dir(), f"created_{prefix}_{stamp}_{rand}")
+
+ with open(base + ".json", "w", encoding="utf-8") as f:
+ json.dump(results, f, indent=4, ensure_ascii=False)
+
+ with open(base + ".txt", "w", encoding="utf-8") as f:
+ f.write("TikTok account creation report\n" + "=" * 44 + "\n\n")
+ for r in results:
+ f.write(f"Target : {r['target']}\n")
+ d = r["data"]
+ if "error" in d:
+ f.write(f" ERROR: {d['error']}\n\n")
+ continue
+ if d.get("type") == "account":
+ f.write(f" Username : @{d.get('username')}\n")
+ f.write(f" Account created: {d.get('account_created')}\n")
+ f.write(f" Nickname : {d.get('nickname')}\n")
+ f.write(f" Verified : {d.get('verified')}\n")
+ f.write(f" Private : {d.get('private')}\n")
+ f.write(f" Followers : {d.get('followers')}\n")
+ f.write(f" Likes : {d.get('likes')}\n")
+ f.write(f" Videos : {d.get('videos')}\n")
+ f.write(f" Bio : {d.get('bio')}\n")
+ f.write(f" User ID : {d.get('user_id')}\n")
+ elif d.get("type") == "video":
+ f.write(f" Video uploaded : {d.get('uploaded')}\n")
+ f.write(f" (video id {d.get('video_id')})\n")
+ else:
+ f.write(f" Decoded id : {d.get('decoded')}\n")
+ f.write("\n")
+ return base + ".json", base + ".txt"
+
+
+# ------------------------------------------------------------------ main
+def new_session():
+ s = requests.Session()
+ s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"})
+ return s
+
+
+def lookup(target, session):
+ """Resolve one target to its result dict. Never raises."""
+ kind, val = classify(target)
+ try:
+ if kind == "video":
+ return kind, decode_video(val)
+ if kind == "id":
+ return kind, decode_id(val)
+ return kind, fetch_user(val, session)
+ except Exception as e:
+ return kind, {"error": f"{type(e).__name__}: {e}"}
+
+
+def show(data, indent=" "):
+ """Print a one-line human-readable result."""
+ if "error" in data:
+ print(Fore.RED + f"{indent}β οΈ {data['error']}")
+ elif data.get("type") == "account":
+ if data.get("account_created"):
+ print(Fore.GREEN + f"{indent}π
created: {data['account_created']} "
+ + Fore.WHITE + f"(@{data['username']}, {data.get('followers')} followers)")
+ else:
+ print(Fore.YELLOW + f"{indent}π‘ profile found but no createTime; "
+ f"id-estimate: {data.get('created_estimate_from_id')}")
+ elif data.get("type") == "video":
+ print(Fore.GREEN + f"{indent}π
uploaded: {data['uploaded']}")
+ else:
+ print(Fore.GREEN + f"{indent}π
decoded: {data['decoded']}")
+
+
+def run_batch(targets, session):
+ print(Fore.CYAN + f"\n[+] Targets: {len(targets)} (free mode β no API key)\n")
+ results = []
+ for i, target in enumerate(targets, 1):
+ print(Fore.WHITE + f"[{i}/{len(targets)}] {target}")
+ kind, data = lookup(target, session)
+ show(data)
+ results.append({"target": target, "data": data})
+ if kind == "user" and i < len(targets):
+ time.sleep(DELAY)
+ return results
+
+
+def run_interactive(session):
+ print(Fore.CYAN + "\nπ TikTok creation-date lookup (free β no API key)")
+ print(Fore.WHITE + " Type a username, @handle, or profile/video URL.")
+ print(Fore.WHITE + " Press Enter on an empty line (or type 'q') to quit.\n")
+ results = []
+ while True:
+ try:
+ entry = input("π username> ").strip()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ break
+ if not entry or entry.lower() in {"q", "quit", "exit"}:
+ break
+ _, data = lookup(entry, session)
+ show(data, indent=" ")
+ results.append({"target": entry, "data": data})
+ return results
+
+
+def main():
+ p = argparse.ArgumentParser(
+ description="Get a TikTok account's creation date β free, no API key. "
+ "Run with no arguments for interactive mode.")
+ p.add_argument("targets", nargs="*",
+ help="one or more usernames / @handles / profile or video URLs")
+ p.add_argument("--input", help="a single target (same as a positional arg)")
+ p.add_argument("--file", help="text file with one target per line")
+ args = p.parse_args()
+
+ # Command-line targets: zsh (unlike bash) does NOT strip inline '# comments',
+ # so a '#' token arrives as a literal arg. Treat it as start-of-comment and
+ # ignore it plus everything after β a pasted "user # note" stays clean.
+ cli = []
+ for t in [*args.targets, *( [args.input] if args.input else [] )]:
+ if t.strip().startswith("#"):
+ break
+ cli.append(t)
+
+ # File targets: skip blank lines and whole-line '#' comments.
+ file_lines = []
+ if args.file:
+ with open(args.file, encoding="utf-8") as f:
+ file_lines = [ln.strip() for ln in f
+ if ln.strip() and not ln.strip().startswith("#")]
+
+ # de-dupe in order, drop blanks
+ seen, targets = set(), []
+ for t in [*cli, *file_lines]:
+ t = t.strip()
+ if not t or t in seen:
+ continue
+ seen.add(t)
+ targets.append(t)
+
+ session = new_session()
+
+ if targets:
+ results = run_batch(targets, session)
+ prefix = "single" if len(targets) == 1 else "batch"
+ else:
+ results = run_interactive(session)
+ prefix = "interactive"
+
+ if results:
+ jp, tp = save_reports(results, prefix)
+ print(Fore.CYAN + f"\nπ Reports:\n {jp}\n {tp}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tiktok_ui.py b/tiktok_ui.py
new file mode 100644
index 0000000..e6251d2
--- /dev/null
+++ b/tiktok_ui.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""
+tiktok_ui.py β a pretty terminal UI for TikTok account creation-date lookups.
+
+Wraps the free, no-API logic from tiktok_created.py in a Rich interface:
+type a username (or @handle / profile URL / video URL) and get a clean card
+showing when the account was created, plus profile stats. No API key or signup.
+
+Launch it with ./start.sh (or the `tokintel` alias).
+
+Part of TokIntel (https://github.com/HackUnderway/TokIntel) by Victor Bancayan /
+Hack Underway. This lookup UI (no API key needed) was contributed by @Thyfwx.
+"""
+import os
+import sys
+
+from rich.console import Console, Group
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+from rich.align import Align
+from rich.prompt import Prompt
+from rich import box
+
+# Reuse the validated lookup engine living next to this file.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from tiktok_created import lookup, new_session, save_reports # noqa: E402
+
+console = Console()
+
+TIKTOK_CYAN = "#25F4EE"
+TIKTOK_RED = "#FE2C55"
+TT_LOGO = [
+ "βββββββββββββββ ββββββββββββ βββββββ βββ βββ",
+ "βββββββββββββββ βββββββββββββββββββββββββ ββββ",
+ " βββ ββββββββββ βββ βββ ββββββββββ ",
+ " βββ ββββββββββ βββ βββ ββββββββββ ",
+ " βββ ββββββ βββ βββ ββββββββββββ βββ",
+ " βββ ββββββ βββ βββ βββββββ βββ βββ",
+]
+
+
+def num(x):
+ return f"{x:,}" if isinstance(x, int) else ("β" if x in (None, "") else str(x))
+
+
+def header():
+ # TikTok-style wordmark: cyan on the left, white core, red on the right.
+ w = max(len(line) for line in TT_LOGO)
+ a, b = w // 3, 2 * w // 3
+ logo = Text(justify="center")
+ for line in TT_LOGO:
+ line = line.ljust(w)
+ logo.append(line[:a], style=f"bold {TIKTOK_CYAN}")
+ logo.append(line[a:b], style="bold white")
+ logo.append(line[b:] + "\n", style=f"bold {TIKTOK_RED}")
+
+ tag = Text(justify="center")
+ tag.append("βͺ ", style=TIKTOK_CYAN)
+ tag.append("Account Lookup", style="bold white")
+ tag.append(" βͺ", style=TIKTOK_RED)
+ sub = Text("when was this account created? Β· no API key needed",
+ style="dim italic", justify="center")
+ credit = Text("part of TokIntel Β· by Hack Underway", style="dim", justify="center")
+
+ body = Group(logo, tag, Text(""), sub, credit)
+ console.print(Panel(body, box=box.DOUBLE, border_style=TIKTOK_CYAN, padding=(1, 3)))
+
+
+def render_account(d):
+ created = d.get("account_created") or "unknown"
+ tbl = Table.grid(padding=(0, 2))
+ tbl.add_column(justify="right", style="cyan", no_wrap=True)
+ tbl.add_column(style="white")
+ if d.get("nickname"):
+ tbl.add_row("Name", str(d["nickname"]))
+ tbl.add_row("Verified", "β
yes" if d.get("verified") else "no")
+ tbl.add_row("Private", "π yes" if d.get("private") else "no")
+ tbl.add_row("Followers", num(d.get("followers")))
+ tbl.add_row("Following", num(d.get("following")))
+ tbl.add_row("Likes", num(d.get("likes")))
+ tbl.add_row("Videos", num(d.get("videos")))
+ if d.get("region"):
+ tbl.add_row("Region", str(d["region"]))
+ if d.get("bio"):
+ tbl.add_row("Bio", str(d["bio"]))
+ tbl.add_row("User ID", str(d.get("user_id")))
+
+ body = Group(
+ Align.center(Text(f"π
{created}", style="bold green")),
+ Align.center(Text("account created", style="dim")),
+ Text(""),
+ tbl,
+ )
+ console.print(Panel(body, title=f"[bold magenta]@{d.get('username')}[/]",
+ border_style="green", box=box.ROUNDED, padding=(1, 2)))
+
+
+def render_simple(title, line, note=None, color="green"):
+ body = [Align.center(Text(line, style=f"bold {color}"))]
+ if note:
+ body.append(Align.center(Text(note, style="dim")))
+ console.print(Panel(Group(*body), title=title, border_style=color,
+ box=box.ROUNDED, padding=(1, 2)))
+
+
+def render(data):
+ if "error" in data:
+ render_simple("not found", data["error"], color="red")
+ elif data.get("type") == "account":
+ render_account(data)
+ elif data.get("type") == "video":
+ render_simple("video", f"π
uploaded {data.get('uploaded')}",
+ note="video upload time, not account creation")
+ else:
+ render_simple("id decode", f"π
{data.get('decoded')}", note=data.get("note"))
+
+
+def main():
+ console.clear()
+ header()
+ console.print(
+ " Type a [bold cyan]username[/], @handle, or profile/video URL. "
+ "Empty line or [bold]q[/] to quit.\n")
+
+ session = new_session()
+ results = []
+ while True:
+ try:
+ entry = Prompt.ask("[bold magenta]π lookup[/]", default="", show_default=False).strip()
+ except (EOFError, KeyboardInterrupt):
+ break
+ if not entry or entry.lower() in {"q", "quit", "exit"}:
+ break
+ with console.status(f"[cyan]Fetching {entry}β¦[/]", spinner="dots"):
+ _, data = lookup(entry, session)
+ render(data)
+ results.append({"target": entry, "data": data})
+
+ if results:
+ _, tp = save_reports(results, "ui")
+ console.print(f"\n[dim]Looked up {len(results)} Β· report saved β[/] {tp}")
+ console.print("\n[magenta]bye π[/]\n")
+
+
+if __name__ == "__main__":
+ main()