diff --git a/.env b/.env deleted file mode 100644 index 3140abaa..00000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -INSTAGRAM_SESSION_ID= -API_URL=https://ai.blackbird.run \ No newline at end of file diff --git a/README.md b/README.md index 74c6300b..4f0d1e09 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,193 @@ of these programs or any derivatives thereof. By using these programs you accept that any damage (dataloss, system crash, system compromise, etc.) caused by the use of these programs is not P1ngul1n0's responsibility. ``` + + +# Blackbird — OSINT Username & Email Reconnaissance Tool (Updated) + +Blackbird checks a given username or email address against hundreds of sites +(social, gaming, coding, dating, finance, and more) to find where an account +exists. When it finds a match on Instagram, it can also pull additional +metadata (obfuscated email/phone) if you provide a valid, logged-in session. + +This copy includes a set of fixes and additions on top of the original +project — see [Changes in This Copy](#changes-in-this-copy) below. + +--- + +## Quick Start + +```bash +cd blackbird +pip install -r requirements.txt +python blackbird.py +``` + +Running it with no arguments launches **interactive mode** — it will ask you: + +1. Whether to search a **username** or an **email** +2. The target(s) to search (comma-separated for multiple) +3. Whether to filter to a specific category or site +4. Whether to export a PDF/CSV report +5. For your **Instagram session ID**, if you want enhanced metadata (email/phone) + +You'll then see a live progress bar, followed by a results table **grouped by +category** (social, coding, gaming, etc.), with any extra Instagram metadata +shown in its own column. + +--- + +## Command-Line Usage + +You don't have to use interactive mode — all the original flags still work: + +```bash +# Search a single username across all sites +python blackbird.py -u ted + +# Search multiple usernames +python blackbird.py -u ted alice bob + +# Search a username, restricted to one category +python blackbird.py -u ted --filter "cat=social" + +# Search a username, restricted to a specific site (partial match) +python blackbird.py -u ted --filter "name~Instagram" + +# Search an email +python blackbird.py -e ted@example.com + +# Export results +python blackbird.py -u ted --pdf --csv --json + +# See how many sites/categories are currently loaded +python blackbird.py --stats +``` + +Run `python blackbird.py -h` for the full flag list. + +### Filter syntax + +`--filter` takes `property=value` (or `~`, `>`, `<`, `>=`, `<=`, `!=`) +expressions, combinable with `and`/`or`: + +``` +--filter "cat=social" +--filter "name~Instagram" +--filter "cat=social and name~insta" +``` + +Valid categories (see `--stats` for the live, current list): `social`, +`gaming`, `coding`, `tech`, `hobby`, `misc`, `finance`, `business`, +`shopping`, `images`, `music`, `blog`, `art`, `dating`, `political`, `news`, +`video`, `archived`, `health`, plus `xx NSFW xx` (excluded automatically with +`--no-nsfw`). + +--- + +## Instagram Enhanced Metadata (Email/Phone) + +Instagram's public profile page doesn't expose email/phone, but Instagram's +own "forgot password" style lookup does, obfuscated (e.g. `t***@gmail.com`). +Blackbird can use this — but only with a **valid, logged-in session cookie** +belonging to your own Instagram account. + +### Getting your session ID + +1. Log in to instagram.com in a browser, using your own account. +2. Open DevTools (`F12`) → **Application** tab → **Cookies** → `instagram.com`. +3. Copy the value of the cookie named `sessionid`. +4. Either: + - Paste it in when the app **prompts you** for it (recommended — no file + to manage), or + - Save it permanently in a `.env` file in the repo root: + ```env + INSTAGRAM_SESSION_ID=your_real_sessionid_value_here + API_URL=https://ai.blackbird.run + ``` + +**This value is equivalent to a login credential for your account.** Don't +share it, commit it to version control, or paste it anywhere public. It also +expires/rotates periodically — if enhanced lookups stop working, get a fresh +one. + +### Reading the warnings + +| Message | Meaning | Fix | +|---|---|---| +| `INSTAGRAM_SESSION_ID is not set` | No session ID configured | Provide one when prompted, or add it to `.env` | +| `Session rejected (HTTP 401/403)` | Session expired or invalid | Log in again, grab a fresh `sessionid` | +| `Enhanced metadata lookup failed` | Ran but returned nothing | Re-run with `--verbose`; refresh session ID | +| `Unexpected response... no user data returned` | Account private/nonexistent, or Instagram changed its API | Verify manually | + +--- + +## Site Coverage + +Blackbird's site list is pulled live from the open-source +[WhatsMyName project](https://github.com/WebBreacher/WhatsMyName) and updates +automatically on every run (unless you pass `--no-update`), so it's always +tracking the current upstream list rather than a fixed, aging snapshot. + +Run `python blackbird.py --stats` at any time to see the current total and a +per-category breakdown. + +--- + +## Exporting Results + +| Flag | Output | +|---|---| +| `--pdf` | PDF report | +| `--csv` | CSV file | +| `--json` | JSON file | +| `--dump` | Saves raw HTML of each found page | + +Exports are written to a folder named `__blackbird/` in the +repo root. + +--- + +## Changes in This Copy. + +Fixes and additions made on top of the upstream project: + +- **Interactive terminal app mode** — running `python blackbird.py` with no + arguments now launches a guided menu (search type → target → filters → + export options → Instagram session), instead of requiring you to already + know the CLI flags. +- **Categorized results table** — every search (interactive or CLI) now ends + with a table grouped by category, with a dedicated column for any + Instagram email/phone metadata recovered. +- **Fixed a crash** (`AttributeError: 'list' object has no attribute + 'strip'`) that occurred when Instagram metadata values came back as a list + instead of a plain string — the results table now handles strings, lists, + dicts, and `None` values safely, and a rendering issue in the table can no + longer crash a search that already succeeded. +- **Fixed a crash** (`IndexError: list index out of range`) that occurred + when `--filter` (or the interactive filter prompt) was given a malformed + value, e.g. typing a bare site name like `Instagram` instead of + `name=Instagram`. Malformed filters now show a clear error instead of + crashing, and the interactive prompt auto-converts a bare word into a + `name~value` filter. +- **Instagram session ID is no longer silently skipped** — if it's missing + or has expired, the console now tells you exactly which case it is + (missing / rejected / malformed response) instead of quietly falling back + to username-only results. +- **Interactive session ID prompt** — if `INSTAGRAM_SESSION_ID` isn't set in + `.env`, the app asks for it directly at runtime (only when running in a + real interactive terminal; scripted/piped runs skip it automatically + rather than hanging). +- **`--stats` flag** — see current site/category counts at a glance. +- This README. + +--- + +## Legal & Ethical Use + +This tool is for OSINT research on accounts and information you are +authorized to look into — for example, your own accounts, accounts you have +explicit consent to test, or authorized security assessments. Your +`INSTAGRAM_SESSION_ID` is your own account's live credential; treat it +accordingly, and use this tool in line with the Terms of Service of every +site it queries. diff --git a/blackbird.py b/blackbird.py index b542c9e0..f2b9965b 100644 --- a/blackbird.py +++ b/blackbird.py @@ -1,5 +1,6 @@ import os import argparse +import re from rich.console import Console import logging import sys @@ -24,6 +25,53 @@ from modules.utils.permute import Permute from dotenv import load_dotenv + +def sanitize_report_identifier(value: str) -> str: + identifier = re.sub(r"[^A-Za-z0-9_-]", "_", value) + identifier = re.sub(r"_+", "_", identifier).strip("_") + return identifier or "report" + + +def generate_similar_usernames(usernames): + variants = [] + seen = set() + for username in usernames: + username = username.strip() + if not username or username in seen: + continue + seen.add(username) + variants.append(username) + + if len(username) >= 3: + prefix2 = username[:2] + suffix2 = username[-2:] + prefix3 = username[:3] + suffix3 = username[-3:] + + candidates = [ + username + prefix2, + username + suffix2, + username + prefix3, + username + suffix3, + prefix2 + username, + suffix2 + username, + prefix3 + username, + suffix3 + username, + username[::-1], + username[1:] if len(username) > 1 else username, + username[:-1] if len(username) > 1 else username, + username + "1", + username + "_", + "_" + username, + ] + for candidate in candidates: + candidate = candidate.strip() + if candidate and candidate not in seen: + seen.add(candidate) + variants.append(candidate) + + return variants + load_dotenv() @@ -90,6 +138,12 @@ def initiate(): help="Generate a JSON with the results." ) + parser.add_argument( + "--similar", + action="store_true", + help="Also search close username variants automatically." + ) + parser.add_argument( "-v", "--verbose", action="store_true", @@ -131,9 +185,15 @@ def initiate(): parser.add_argument( "--about", action="store_true", help="Show about information and exit." ) + parser.add_argument( + "--stats", + action="store_true", + help="Show how many sites/categories are currently loaded, then exit.", + ) args = parser.parse_args() # Store the necessary arguments to config Object + config.stats = args.stats config.username = args.username config.username_file = args.username_file config.permute = args.permute @@ -154,11 +214,48 @@ def initiate(): config.email_file = args.email_file config.no_update = args.no_update config.about = args.about + config.similar = args.similar config.instagram_session_id = os.getenv("INSTAGRAM_SESSION_ID") config.api_url = os.getenv("API_URL") config.console = Console() + # If no Instagram session ID is configured in .env, prompt for one + # interactively — but only when this run will actually search usernames + # (Instagram lookups don't apply to email-only searches, --about, or + # --setup-ai), and only when stdin is a real interactive terminal. + doing_username_search = bool(config.username or config.username_file) + if ( + not config.instagram_session_id + and doing_username_search + and not config.about + and not config.setup_ai + and sys.stdin.isatty() + ): + config.console.print( + "[bold yellow]⚠️ No INSTAGRAM_SESSION_ID found in .env[/bold yellow]" + ) + config.console.print( + "Enhanced Instagram metadata (obfuscated email/phone) requires a" + " valid sessionid cookie from a logged-in Instagram session." + ) + try: + entered = input( + "Paste your Instagram sessionid now (or press Enter to skip): " + ).strip() + except (EOFError, KeyboardInterrupt): + entered = "" + + if entered: + config.instagram_session_id = entered + config.console.print( + "[green]✔ Using the session ID you entered for this run.[/green]" + ) + else: + config.console.print( + "[yellow]Skipping enhanced Instagram metadata for this run.[/yellow]" + ) + config.dateRaw = datetime.now().strftime("%m_%d_%Y") config.datePretty = datetime.now().strftime("%B %d, %Y") @@ -174,6 +271,148 @@ def initiate(): config.splash_line = random.choice(lines) if lines else "" +def run_interactive_menu(config): + """Full interactive terminal app mode — launched when blackbird.py is run + with no CLI arguments. Prompts for search type, target, and options, + then runs the search and shows a categorized results view.""" + from rich.prompt import Prompt, Confirm + + if not sys.stdin.isatty(): + config.console.print("Either --username or --email is required") + sys.exit() + + config.console.print("\n[bold magenta]═══ Blackbird Interactive Mode ═══[/bold magenta]\n") + + search_type = Prompt.ask( + "[cyan]What do you want to search?[/cyan]", + choices=["username", "email"], + default="username", + ) + + if search_type == "username": + raw = Prompt.ask("[cyan]Enter username(s)[/cyan] (comma-separated for multiple)") + config.username = [u.strip() for u in raw.split(",") if u.strip()] + if not config.username: + config.console.print("[red]No username entered. Exiting.[/red]") + sys.exit() + + if Confirm.ask("[cyan]Filter to a specific category or site name?[/cyan]", default=False): + raw_filter = Prompt.ask( + " Enter filter (e.g. [dim]cat=social[/dim] or [dim]name=Instagram[/dim], " + "or just type a site name like [dim]Instagram[/dim])" + ).strip() + if raw_filter and not re.search(r"[=~> 1: + config.currentUser = sanitize_report_identifier( + ",".join([u.strip() for u in config.username if u.strip()]) + ) + else: + raw = Prompt.ask("[cyan]Enter email(s)[/cyan] (comma-separated for multiple)") + config.email = [e.strip() for e in raw.split(",") if e.strip()] + if not config.email: + config.console.print("[red]No email entered. Exiting.[/red]") + sys.exit() + if Confirm.ask("[cyan]Save results to a PDF report when done?[/cyan]", default=True): + config.pdf = True + + config.console.print("") + + +def display_categorized_results(foundAccounts, config, target_label): + """Print a results table grouped by site category (Social, Gaming, etc.), + highlighting any extra metadata (e.g. Instagram email/phone) recovered. + Never lets a display issue crash a search that already succeeded.""" + from rich.table import Table + from collections import defaultdict + + if not foundAccounts: + return + + try: + by_category = defaultdict(list) + for account in foundAccounts: + category = account.get("category") or "Other" + by_category[category].append(account) + + total = len(foundAccounts) + config.console.print( + f"\n[bold green]📊 Found {total} account(s) for \"{target_label}\" " + f"across {len(by_category)} categor{'y' if len(by_category) == 1 else 'ies'}:[/bold green]\n" + ) + + table = Table( + title=f'Blackbird Results — "{target_label}"', + show_lines=False, + header_style="bold white on magenta", + title_style="bold cyan", + expand=True, + ) + table.add_column("Category", style="cyan", no_wrap=True) + table.add_column("Site", style="bold white") + table.add_column("URL", style="bright_blue", overflow="fold") + table.add_column("Extra Data", style="yellow") + + for category in sorted(by_category.keys(), key=lambda c: str(c)): + accounts = sorted(by_category[category], key=lambda a: str(a.get("name", ""))) + for account in accounts: + extra = "" + metadata = account.get("metadata") + if metadata: + parts = [] + for item in metadata: + try: + name = str(item.get("name", "")).strip() + raw_value = item.get("value") + if isinstance(raw_value, (list, tuple, set)): + value = ", ".join( + str(v).strip() for v in raw_value if str(v).strip() + ) + elif isinstance(raw_value, dict): + value = ", ".join( + f"{k}: {v}" for k, v in raw_value.items() + ) + elif raw_value is None: + value = "" + else: + value = str(raw_value).strip() + parts.append(f"{name}: {value}" if value else name) + except Exception: + # Skip a single malformed metadata item rather than + # losing the whole row / whole table. + continue + extra = "\n".join(parts) + table.add_row( + str(category), + str(account.get("name", "")), + str(account.get("url", "")), + extra, + ) + + config.console.print(table) + config.console.print("") + except Exception as e: + config.console.print( + f"[yellow]⚠️ Couldn't render the categorized results table ({e})." + f" Your {total if 'total' in dir() else len(foundAccounts)} results were still found" + " — use --pdf, --csv, or --json to export them.[/yellow]" + ) + + if __name__ == "__main__": initiate() config.console.print( @@ -205,6 +444,33 @@ def initiate(): ) sys.exit() + if config.stats: + if config.no_update: + config.console.print(":next_track_button: Skipping update...") + else: + checkUpdates(config) + from modules.whatsmyname.list_operations import readList + from collections import Counter + + data = readList("username", config) + sites = data["sites"] + cats = Counter(s["cat"] for s in sites) + + from rich.table import Table + table = Table(title="Blackbird Site Coverage", header_style="bold white on magenta") + table.add_column("Category", style="cyan") + table.add_column("Sites", style="bold white", justify="right") + for cat, count in sorted(cats.items(), key=lambda x: -x[1]): + table.add_row(cat, str(count)) + config.console.print(table) + config.console.print(f"\n[bold green]Total: {len(sites)} sites across {len(cats)} categories[/bold green]") + config.console.print( + "[dim]This list is pulled live from the WhatsMyName project and updates automatically" + " (unless --no-update is passed) — it's already tracking the largest actively" + " maintained public site list, rather than a fixed bundled one.[/dim]" + ) + sys.exit() + if ( not config.username and not config.email @@ -212,8 +478,7 @@ def initiate(): and not config.email_file and not config.setup_ai ): - config.console.print("Either --username or --email is required") - sys.exit() + run_interactive_menu(config) if not config.username and (config.permute or config.permuteall): config.console.print("Permutations requires --username") sys.exit() @@ -274,37 +539,69 @@ def initiate(): config.console.print( f":glasses: Successfully loaded {len(config.username)} usernames from permuting {elements}" ) + + original_usernames = list(config.username) + if config.similar: + config.username = generate_similar_usernames(config.username) + if len(config.username) > len(original_usernames): + config.console.print( + f":mag: Searching {len(config.username)} close username variants for {', '.join(original_usernames)}" + ) + + combined_accounts = [] + combined_label = ", ".join(original_usernames) + combined_report = len(config.username) > 1 and (config.csv or config.pdf or config.json) + + if combined_report: + config.currentUser = sanitize_report_identifier(combined_label) + createSaveDirectory(config) + for user in config.username: config.currentUser = user - if config.dump or config.csv or config.pdf or config.json: + if not combined_report and (config.dump or config.csv or config.pdf or config.json): createSaveDirectory(config) verifyUsername(config.currentUser, config) - if config.ai: - if len(config.usernameFoundAccounts) > 2: - from modules.ai.client import send_prompt - site_names = [account.get("name", "") for account in config.usernameFoundAccounts] - if (site_names): - prompt = ", ".join(site_names) - - data = send_prompt(prompt, config) - - if (data): - config.ai_analysis = data - else: - config.console.print( - ":warning: Not enough accounts found for AI analysis. Skipping AI features." - ) - - if config.csv and config.usernameFoundAccounts: - saveToCsv(config.usernameFoundAccounts, config) - if config.pdf and config.usernameFoundAccounts: - saveToPdf(config.usernameFoundAccounts, "username", config) - if config.json and config.usernameFoundAccounts: - saveToJson(config.usernameFoundAccounts, config) + if combined_report: + combined_accounts.extend(config.usernameFoundAccounts or []) + else: + display_categorized_results(config.usernameFoundAccounts, config, config.currentUser) + if config.ai: + if len(config.usernameFoundAccounts) > 2: + from modules.ai.client import send_prompt + site_names = [account.get("name", "") for account in config.usernameFoundAccounts] + if site_names: + prompt = ", ".join(site_names) + + data = send_prompt(prompt, config) + + if data: + config.ai_analysis = data + else: + config.console.print( + ":warning: Not enough accounts found for AI analysis. Skipping AI features." + ) + + if config.csv and config.usernameFoundAccounts: + saveToCsv(config.usernameFoundAccounts, config) + if config.pdf and config.usernameFoundAccounts: + saveToPdf(config.usernameFoundAccounts, "username", config) + if config.json and config.usernameFoundAccounts: + saveToJson(config.usernameFoundAccounts, config) config.currentUser = None config.usernameFoundAccounts = None + if combined_report: + display_categorized_results(combined_accounts, config, combined_label) + if config.csv and combined_accounts: + saveToCsv(combined_accounts, config) + if config.pdf and combined_accounts: + saveToPdf(combined_accounts, "username", config) + if config.json and combined_accounts: + saveToJson(combined_accounts, config) + config.currentUser = None + config.usernameFoundAccounts = None + if config.email_file: if isFile(config.email_file): config.email = getLinesFromFile(config.email_file) @@ -316,32 +613,55 @@ def initiate(): sys.exit() if config.email: + combined_accounts = [] + combined_label = ", ".join(config.email) + combined_report = len(config.email) > 1 and (config.csv or config.pdf or config.json) + + if combined_report: + config.currentEmail = sanitize_report_identifier(combined_label) + createSaveDirectory(config) + for email in config.email: config.currentEmail = email - if config.dump or config.csv or config.pdf or config.json: + if not combined_report and (config.dump or config.csv or config.pdf or config.json): createSaveDirectory(config) verifyEmail(email, config) - if config.ai: - if len(config.emailFoundAccounts) > 2: - from modules.ai.client import send_prompt - site_names = [account.get("name", "") for account in config.emailFoundAccounts] - if (site_names): - prompt = ", ".join(site_names) + if combined_report: + combined_accounts.extend(config.emailFoundAccounts or []) + else: + display_categorized_results(config.emailFoundAccounts, config, email) + if config.ai: + if len(config.emailFoundAccounts) > 2: + from modules.ai.client import send_prompt + site_names = [account.get("name", "") for account in config.emailFoundAccounts] + if site_names: + prompt = ", ".join(site_names) - data = send_prompt(prompt, config) - - if (data): - config.ai_analysis = data - else: - config.console.print( - ":warning: Not enough accounts found for AI analysis. Skipping AI features." - ) - - if config.csv and config.emailFoundAccounts: - saveToCsv(config.emailFoundAccounts, config) - if config.pdf and config.emailFoundAccounts: - saveToPdf(config.emailFoundAccounts, "email", config) - if config.json and config.emailFoundAccounts: - saveToJson(config.emailFoundAccounts, config) + data = send_prompt(prompt, config) + + if data: + config.ai_analysis = data + else: + config.console.print( + ":warning: Not enough accounts found for AI analysis. Skipping AI features." + ) + + if config.csv and config.emailFoundAccounts: + saveToCsv(config.emailFoundAccounts, config) + if config.pdf and config.emailFoundAccounts: + saveToPdf(config.emailFoundAccounts, "email", config) + if config.json and config.emailFoundAccounts: + saveToJson(config.emailFoundAccounts, config) + config.currentEmail = None + config.emailFoundAccounts = None + + if combined_report: + display_categorized_results(combined_accounts, config, combined_label) + if config.csv and combined_accounts: + saveToCsv(combined_accounts, config) + if config.pdf and combined_accounts: + saveToPdf(combined_accounts, "email", config) + if config.json and combined_accounts: + saveToJson(combined_accounts, config) config.currentEmail = None config.emailFoundAccounts = None diff --git a/src/modules/core/email.py b/src/modules/core/email.py index 62b86ee6..f04671e9 100644 --- a/src/modules/core/email.py +++ b/src/modules/core/email.py @@ -20,11 +20,18 @@ from ..utils.precheck import perform_pre_check +def get_display_url(site, account): + if site.get("uri_pretty"): + return site["uri_pretty"].replace("{account}", account) + return site["uri_check"].replace("{account}", account) + + # Verify account existence based on list args async def checkSite( site, method, - url, + request_url, + display_url, session, semaphore, config, @@ -33,7 +40,7 @@ async def checkSite( ): returnData = { "name": site["name"], - "url": url, + "url": display_url, "category": site["cat"], "status": "NONE", "metadata": None, @@ -48,7 +55,7 @@ async def checkSite( returnData["status"] = "ERROR" return returnData - response = await do_async_request(method, url, session, config, data, headers) + response = await do_async_request(method, request_url, session, config, data, headers) if response == None: returnData["status"] = "ERROR" return returnData @@ -62,7 +69,7 @@ async def checkSite( ): returnData["status"] = "FOUND" config.console.print( - rf" ✔️ \[[cyan1]{site['name']}[/cyan1]] [bright_white]{response['url']}[/bright_white]" + rf" ✔️ \[[cyan1]{site['name']}[/cyan1]] [bright_white]{display_url}[/bright_white]" ) if site["metadata"]: extractedMetadata = extractMetadata( @@ -124,10 +131,12 @@ async def wrappedCheck(site): data = site["data"].replace("{account}", email_processed) if site["data"] else None headers = site["headers"] if site["headers"] else None + display_url = get_display_url(site, email_processed) result = await checkSite( site=site, method=site["method"], - url=url, + request_url=url, + display_url=display_url, session=session, semaphore=semaphore, config=config, diff --git a/src/modules/core/username.py b/src/modules/core/username.py index a7fb69ff..e28d42d7 100644 --- a/src/modules/core/username.py +++ b/src/modules/core/username.py @@ -24,18 +24,25 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "..")) +def get_display_url(site, account): + if site.get("uri_pretty"): + return site["uri_pretty"].replace("{account}", account) + return site["uri_check"].replace("{account}", account) + + # Verify account existence based on list args async def checkSite( site, method, - url, + request_url, + display_url, session, semaphore, config, ): returnData = { "name": site["name"], - "url": url, + "url": display_url, "category": site["cat"], "status": "NONE", "metadata": None, @@ -43,7 +50,7 @@ async def checkSite( extractedMetadata = [] async with semaphore: - response = await do_async_request(method, url, session, config) + response = await do_async_request(method, request_url, session, config) if response == None: returnData["status"] = "ERROR" return returnData @@ -59,7 +66,7 @@ async def checkSite( ): returnData["status"] = "FOUND" config.console.print( - rf" ✔️ \[[cyan1]{site['name']}[/cyan1]] [bright_white]{response['url']}[/bright_white]" + rf" ✔️ \[[cyan1]{site['name']}[/cyan1]] [bright_white]{display_url}[/bright_white]" ) if site["name"] in config.metadata_params["sites"]: @@ -84,8 +91,21 @@ async def checkSite( config.instagram_session_id, config, ) - extractedMetadata.sort(key=lambda x: x["name"]) - extractedMetadata.extend(metadata) + if metadata: + extractedMetadata.sort(key=lambda x: x["name"]) + extractedMetadata.extend(metadata) + else: + config.console.print( + r" ⚠️ [Instagram] Enhanced metadata (email/phone) lookup failed" + " — your INSTAGRAM_SESSION_ID may be expired or invalid." + " Re-run with --verbose for details." + ) + else: + config.console.print( + r" ⚠️ [Instagram] Skipping enhanced metadata (email/phone) lookup" + " — INSTAGRAM_SESSION_ID is not set in your .env file." + " See docs/advanced-usage.md." + ) if extractedMetadata and len(extractedMetadata) > 0: extractedMetadata = remove_duplicates(extractedMetadata) @@ -134,10 +154,13 @@ def render(): async def wrappedCheck(site): nonlocal completed + request_url = site["uri_check"].replace("{account}", username) + display_url = get_display_url(site, username) result = await checkSite( site=site, method="GET", - url=site["uri_check"].replace("{account}", username), + request_url=request_url, + display_url=display_url, session=session, semaphore=semaphore, config=config, diff --git a/src/modules/sites/instagram.py b/src/modules/sites/instagram.py index b2eb2917..0a9a2b90 100644 --- a/src/modules/sites/instagram.py +++ b/src/modules/sites/instagram.py @@ -125,7 +125,30 @@ def get_user_id(username, session_id, config): customHeaders=headers, cookies=cookies, ) - user_id = response.json()["data"]["user"]["id"] + + # A bad/expired sessionid typically comes back as HTTP 401/403, + # or a 200 with no "data" key. Surface that distinctly instead of + # letting it fall through to a generic KeyError below. + if response.status_code in (401, 403): + config.console.print( + " ⚠️ [Instagram] Session rejected (HTTP " + f"{response.status_code}) — your INSTAGRAM_SESSION_ID is" + " likely expired or invalid. Grab a fresh sessionid cookie" + " and update your .env file." + ) + return False + + body = response.json() + if "data" not in body or "user" not in body.get("data", {}): + config.console.print( + " ⚠️ [Instagram] Unexpected response while acquiring user" + f" ID for {username} (no user data returned). The account" + " may be private/nonexistent, or Instagram's response" + " format has changed." + ) + return False + + user_id = body["data"]["user"]["id"] if config.verbose: config.console.print(f"[Instagram] Acquired {username} user ID") return user_id diff --git a/src/modules/utils/filter.py b/src/modules/utils/filter.py index ef30b38b..af77763a 100644 --- a/src/modules/utils/filter.py +++ b/src/modules/utils/filter.py @@ -53,13 +53,15 @@ def evaluate_condition(prop, operator, value, site): def filterAccounts(filter, site): conditions, logical_ops = parseFilter(filter) - result = evaluate_condition(*conditions[0], site) if not conditions: print( - '⭕ Filter is not in correct format. Format should be --filter "property=value"' + '⭕ Filter is not in correct format. Format should be --filter "property=value" (e.g. name=Instagram or cat=social)' ) sys.exit() + + result = evaluate_condition(*conditions[0], site) + # Evaluate remaining conditions and combine using logical operators for i in range(1, len(conditions)): next_result = evaluate_condition(*conditions[i], site)