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()