Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Submit feature requests and bug reports in the [issues](https://github.com/vil/H

| # | Tool | Description |
|---|------|-------------|
| 01 | **Ig Scrape** | Two-track Instagram OSINT scraper. **Guest mode** (no login) uses the `ensta` Guest API for public profile data and recent posts. **Authenticated mode** (Instagram `sessionid` cookie) uses [`toutatis`](https://github.com/megadose/toutatis) via Instagram's private mobile API for richer data - business flags, IGTV count, WhatsApp link status, and publicly listed contact details. Both tracks run Toutatis `advanced_lookup` to surface obfuscated email and phone from Instagram's account-recovery flow. Results can be exported to `scraped_data/` as **TXT**, **CSV**, or **JSON**. |
| 01 | **Ig Scrape** | Two-track Instagram OSINT scraper. **Guest mode** (no login) uses the `ensta` Guest API for public profile data and recent posts. **Authenticated mode** (Instagram `sessionid` cookie) uses [`toutatis`](https://github.com/megadose/toutatis) via Instagram's private mobile API for richer data - business flags, IGTV count, WhatsApp link status, and publicly listed contact details. Session IDs can optionally be saved in `$HOME/.config/h4x-tools/config.json` so they do not need to be re-entered every run. Both tracks run Toutatis `advanced_lookup` to surface obfuscated email and phone from Instagram's account-recovery flow. Results can be exported to `scraped_data/` as **TXT**, **CSV**, or **JSON**. |
| 02 | **Web Reconnaissance** | Multi-mode OSINT search powered by the `ddgs` library. Choose from 7 modes: **General** (free-form), **Person** (12 dorks), **Email** (8 dorks), **Domain** (12 recon dorks), **Username** (12 platform dorks), **Phone Number** (8 dorks), or **Custom Dork** (write your own template). Configurable result count, retry/back-off on rate limits. Results can be exported to `scraped_data/` as **TXT**, **CSV**, or **JSON**. |
| 03 | **Phone Lookup** | Validates and analyses a phone number via the `phonenumbers` library (E.164/national/international formats, country, region, carrier, line type, time zones), then runs [`ignorant`](https://github.com/megadose/ignorant) to check social-media platform registrations. |
| 04 | **IP Lookup** | Resolves a hostname or IP address and queries [ipinfo.io](https://ipinfo.io) for geolocation data - city, region, country, coordinates, ISP/organization, postal code, and timezone - with a direct OpenStreetMap link. |
Expand Down
4 changes: 3 additions & 1 deletion h4xtools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from colorama import Fore, Style

from helper import handles, printer
from helper import config, handles, printer

VERSION = "26"

Expand Down Expand Up @@ -77,6 +77,7 @@ def _display_help() -> None:
"Two-track Instagram OSINT scraper. **Guest mode** (no login) uses the `ensta` Guest API for public profile data and recent posts. "
"**Authenticated mode** (Instagram `sessionid` cookie) uses [`toutatis`](https://github.com/megadose/toutatis) "
"via Instagram's private mobile API for richer data — business flags, IGTV count, WhatsApp link status, and publicly listed contact details. "
"Session IDs can optionally be saved in `$HOME/.config/h4x-tools/config.json` so they do not need to be re-entered every run. "
"Both tracks run Toutatis `advanced_lookup` to surface obfuscated email and phone from Instagram's account-recovery flow. "
"Results can be exported to `scraped_data/` as **TXT**, **CSV**, or **JSON**."
),
Expand Down Expand Up @@ -217,6 +218,7 @@ def _print_menu() -> None:


def main() -> None:
config.init_config()
_internet_check()
time.sleep(0.5)

Expand Down
190 changes: 190 additions & 0 deletions helper/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""
Copyright (c) 2023-2026. Vili and contributors.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import json
import os
from pathlib import Path
from typing import Any

from helper import printer

BASE = "h4x-tools"
CONFIG_FILENAME = "config.json"


def get_config_dir() -> Path:
"""
Return the H4X-Tools config directory.

Uses the XDG base directory convention when ``XDG_CONFIG_HOME`` is set,
otherwise falls back to ``$HOME/.config/h4x-tools``.

:return: Config directory path.
"""
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
base_dir = (
Path(xdg_config_home).expanduser()
if xdg_config_home
else Path.home() / ".config"
)
return base_dir / BASE


def get_config_file() -> Path:
"""
Return the H4X-Tools JSON config file path.

:return: Config file path.
"""
return get_config_dir() / CONFIG_FILENAME


def init_config() -> Path | None:
"""
Ensure the config directory and JSON config file exist.

The directory is created with owner-only permissions where the platform
supports it. The file starts as an empty JSON object.

:return: Config file path, or ``None`` if initialization fails.
"""
config_dir = get_config_dir()
config_file = get_config_file()

try:
config_dir.mkdir(parents=True, exist_ok=True)
try:
config_dir.chmod(0o700)
except OSError:
pass

if not config_file.exists():
config_file.write_text("{}\n", encoding="utf-8")
try:
config_file.chmod(0o600)
except OSError:
pass

return config_file
except OSError as exc:
printer.warning(f"Could not initialize config path {config_dir}: {exc}")
return None


def load_config() -> dict[str, Any]:
"""
Load the H4X-Tools config JSON.

Invalid or unreadable config files are treated as empty config so tools can
still run without persistent settings.

:return: Parsed config dictionary.
"""
config_file = init_config()
if config_file is None:
return {}

try:
data = json.loads(config_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
printer.warning(f"Config file is invalid JSON ({exc}); ignoring saved config.")
return {}
except OSError as exc:
printer.warning(f"Could not read config file {config_file}: {exc}")
return {}

return data if isinstance(data, dict) else {}


def save_config(data: dict[str, Any]) -> bool:
"""
Save the H4X-Tools config JSON.

:param data: Config data to persist.
:return: ``True`` when saved successfully, otherwise ``False``.
"""
config_file = init_config()
if config_file is None:
return False

try:
config_file.write_text(
json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
encoding="utf-8",
)
try:
config_file.chmod(0o600)
except OSError:
pass
return True
except OSError as exc:
printer.warning(f"Could not write config file {config_file}: {exc}")
return False


def get_value(section: str, key: str, default: Any = None) -> Any:
"""
Read a config value from a named section.

:param section: Top-level config section.
:param key: Key inside the section.
:param default: Value returned when missing.
:return: Stored value or default.
"""
data = load_config()
section_data = data.get(section, {})
if not isinstance(section_data, dict):
return default
return section_data.get(key, default)


def set_value(section: str, key: str, value: Any) -> bool:
"""
Store a config value in a named section.

:param section: Top-level config section.
:param key: Key inside the section.
:param value: JSON-serializable value to store.
:return: ``True`` when saved successfully, otherwise ``False``.
"""
data = load_config()
section_data = data.get(section)
if not isinstance(section_data, dict):
section_data = {}
data[section] = section_data

section_data[key] = value
return save_config(data)


def delete_value(section: str, key: str) -> bool:
"""
Delete a config value from a named section.

Empty sections are kept to avoid surprising rewrites of unrelated config.

:param section: Top-level config section.
:param key: Key inside the section.
:return: ``True`` when saved successfully or key was absent.
"""
data = load_config()
section_data = data.get(section)
if not isinstance(section_data, dict) or key not in section_data:
return True

del section_data[key]
return save_config(data)
92 changes: 80 additions & 12 deletions utils/ig_scrape.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
from ensta.lib.Exceptions import APIError, NetworkError, RateLimitedError
from toutatis.core import advanced_lookup, getInfo

from helper import printer, timer
from helper import config, printer, timer

_KEY_WIDTH = 24
_SAVE_DIR = Path("scraped_data")
_CONFIG_SECTION = "ig_scrape"
_SESSION_ID_KEY = "session_id"


@dataclass
Expand Down Expand Up @@ -323,6 +325,82 @@ def _print_profile(profile: IGProfile) -> None:
printer.info(f"{'Scraped at':<{_KEY_WIDTH}} : {profile.scraped_at}")


def _ask_session_id() -> str:
"""
Resolve the Instagram session ID from saved config or user input.

Saved values live in ``$HOME/.config/h4x-tools/config.json`` by default,
using the shared H4X-Tools config helper.

:return: Session ID string, or an empty string for guest mode.
"""
saved_session = config.get_value(_CONFIG_SECTION, _SESSION_ID_KEY, "")
if isinstance(saved_session, str) and saved_session.strip():
printer.info("Saved Instagram session ID found in H4X-Tools config.")
printer.info(" 1 : Use saved session ID")
printer.info(" 2 : Enter a different session ID")
printer.info(" 3 : Delete saved session ID and use guest mode")
printer.info(" 4 : Use guest mode once")
choice = printer.user_input("Select [1-4] (default = 1) : ").strip()

match choice:
case "" | "1":
return saved_session.strip()
case "2":
return _prompt_new_session_id()
case "3":
if config.delete_value(_CONFIG_SECTION, _SESSION_ID_KEY):
printer.success("Saved Instagram session ID deleted.")
return ""
case "4":
return ""
case _:
printer.warning("Invalid choice. Using saved session ID.")
return saved_session.strip()

return _prompt_new_session_id()


def _prompt_new_session_id() -> str:
"""
Prompt for a new Instagram session ID and optionally save it.

:return: Session ID string, or an empty string for guest mode.
"""
printer.noprefix("")
printer.info(
"Provide your Instagram session ID for richer data (phone, email, "
"business flags, etc.)."
)
printer.info(
"Find it in your browser: DevTools → Application → Cookies → sessionid"
)
session_id = printer.user_input(
"Session ID (leave blank for guest mode) : "
).strip()

if not session_id:
return ""

printer.warning(
"Session IDs are sensitive cookies. Saving stores it as plaintext in an owner-only config file."
)
answer = (
printer.user_input(
"Save this session ID to H4X-Tools config for future runs? (y/N) : "
)
.strip()
.lower()
)
if answer in {"y", "yes"}:
if config.set_value(_CONFIG_SECTION, _SESSION_ID_KEY, session_id):
printer.success(
f"Session ID saved to {Style.BRIGHT}{config.get_config_file()}{Style.RESET_ALL}"
)

return session_id


def _ask_export() -> str | None:
"""
Prompt the user for an optional export format.
Expand Down Expand Up @@ -504,17 +582,7 @@ def scrape(target: str) -> None:
printer.error("Username cannot be empty.")
return

printer.noprefix("")
printer.info(
"Provide your Instagram session ID for richer data (phone, email, "
"business flags, etc.)."
)
printer.info(
"Find it in your browser: DevTools → Application → Cookies → sessionid"
)
session_id = printer.user_input(
"Session ID (leave blank for guest mode) : "
).strip()
session_id = _ask_session_id()

profile = IGProfile()

Expand Down
Loading