argis 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argis/__init__.py +3 -0
- argis/__main__.py +4 -0
- argis/cli.py +147 -0
- argis/core.py +155 -0
- argis/diff.py +119 -0
- argis/exceptions.py +25 -0
- argis/sites.json +506 -0
- argis/utils/__init__.py +0 -0
- argis/utils/display.py +93 -0
- argis/utils/export.py +44 -0
- argis/utils/network.py +60 -0
- argis-0.1.0.dist-info/METADATA +120 -0
- argis-0.1.0.dist-info/RECORD +15 -0
- argis-0.1.0.dist-info/WHEEL +4 -0
- argis-0.1.0.dist-info/entry_points.txt +2 -0
argis/__init__.py
ADDED
argis/__main__.py
ADDED
argis/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command-line interface for Argis. Parses flags and delegates to core.py
|
|
2
|
+
and diff.py — this module should stay free of scanning/HTTP logic."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from argis import diff as diffmod
|
|
14
|
+
from argis.core import ArgisEngine
|
|
15
|
+
from argis.exceptions import ArgisError
|
|
16
|
+
from argis.utils import display
|
|
17
|
+
from argis.utils.display import console
|
|
18
|
+
from argis.utils.export import export_results
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Argis: the all-seeing username scanner. Hunt down accounts across "
|
|
22
|
+
"dozens of platforms and track how a username's footprint changes over time."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def scan(
|
|
28
|
+
username: str = typer.Argument(..., help="The target username to hunt down."),
|
|
29
|
+
diff: bool = typer.Option(
|
|
30
|
+
False, "--diff", "-d", help="Compare this scan against the last saved scan."
|
|
31
|
+
),
|
|
32
|
+
save: bool = typer.Option(
|
|
33
|
+
True, "--save/--no-save", help="Save this scan to history for future --diff runs."
|
|
34
|
+
),
|
|
35
|
+
proxy: Optional[str] = typer.Option(
|
|
36
|
+
None, "--proxy", help="Route requests through a proxy, e.g. socks5://127.0.0.1:9050"
|
|
37
|
+
),
|
|
38
|
+
tor: bool = typer.Option(
|
|
39
|
+
False, "--tor", help="Route requests through a local Tor SOCKS5 proxy."
|
|
40
|
+
),
|
|
41
|
+
timeout: float = typer.Option(7.0, "--timeout", help="Per-request timeout in seconds."),
|
|
42
|
+
concurrency: int = typer.Option(
|
|
43
|
+
30, "--concurrency", help="Maximum number of simultaneous requests."
|
|
44
|
+
),
|
|
45
|
+
export: Optional[str] = typer.Option(
|
|
46
|
+
None, "--export", help="Export format: csv, json, or markdown."
|
|
47
|
+
),
|
|
48
|
+
output: Optional[Path] = typer.Option(
|
|
49
|
+
None, "-o", "--output", help="Output file path for --export (default: <username>.<ext>)."
|
|
50
|
+
),
|
|
51
|
+
quiet: bool = typer.Option(
|
|
52
|
+
False, "--quiet", "-q", help="Suppress the progress bar and live [+] hits."
|
|
53
|
+
),
|
|
54
|
+
):
|
|
55
|
+
"""Search for a target username across all configured platforms."""
|
|
56
|
+
if not quiet:
|
|
57
|
+
display.print_banner(username)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
engine = ArgisEngine(
|
|
61
|
+
username,
|
|
62
|
+
proxy=proxy,
|
|
63
|
+
use_tor=tor,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
concurrency=concurrency,
|
|
66
|
+
)
|
|
67
|
+
results = asyncio.run(engine.run_scan(quiet=quiet))
|
|
68
|
+
except ArgisError as exc:
|
|
69
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
if not quiet:
|
|
73
|
+
console.print()
|
|
74
|
+
display.print_results_table(results, username)
|
|
75
|
+
display.print_summary(results)
|
|
76
|
+
|
|
77
|
+
if diff:
|
|
78
|
+
previous = diffmod.get_last_scan(username)
|
|
79
|
+
console.print()
|
|
80
|
+
if previous is None:
|
|
81
|
+
console.print(
|
|
82
|
+
"[dim]No previous scan found for this username — nothing to diff "
|
|
83
|
+
"against. This scan will become the baseline.[/dim]"
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
delta = diffmod.compute_diff(previous["results"], results)
|
|
87
|
+
display.print_diff(delta)
|
|
88
|
+
|
|
89
|
+
if save:
|
|
90
|
+
diffmod.save_scan(username, results)
|
|
91
|
+
|
|
92
|
+
if export:
|
|
93
|
+
fmt = export.lower()
|
|
94
|
+
ext = {"json": "json", "csv": "csv", "markdown": "md"}.get(fmt)
|
|
95
|
+
if ext is None:
|
|
96
|
+
console.print(f"[bold red]Unsupported export format:[/bold red] {export}")
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
out_path = output or Path(f"{username}.{ext}")
|
|
99
|
+
export_results(results, username, fmt, out_path)
|
|
100
|
+
console.print(f"[dim]Exported results to {out_path}[/dim]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def history(
|
|
105
|
+
username: str = typer.Argument(..., help="Username whose scan history to display."),
|
|
106
|
+
limit: int = typer.Option(10, "--limit", help="Maximum number of past scans to show."),
|
|
107
|
+
):
|
|
108
|
+
"""Show past scan timestamps and found-profile counts for a username."""
|
|
109
|
+
records = diffmod.load_history(username)
|
|
110
|
+
if not records:
|
|
111
|
+
console.print(f"[dim]No history found for '{username}'.[/dim]")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
table = Table(title=f"Scan history for @{username}")
|
|
115
|
+
table.add_column("#")
|
|
116
|
+
table.add_column("Timestamp")
|
|
117
|
+
table.add_column("Found")
|
|
118
|
+
table.add_column("Total sites")
|
|
119
|
+
|
|
120
|
+
for i, record in enumerate(records[-limit:], start=1):
|
|
121
|
+
found = sum(1 for r in record["results"].values() if r["status"] == "FOUND")
|
|
122
|
+
table.add_row(str(i), record["timestamp"], str(found), str(len(record["results"])))
|
|
123
|
+
|
|
124
|
+
console.print(table)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("clear-history")
|
|
128
|
+
def clear_history(
|
|
129
|
+
username: str = typer.Argument(..., help="Username whose history should be deleted."),
|
|
130
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
131
|
+
):
|
|
132
|
+
"""Delete all saved scan history for a username."""
|
|
133
|
+
if not yes:
|
|
134
|
+
confirmed = typer.confirm(f"Delete all saved history for '{username}'?")
|
|
135
|
+
if not confirmed:
|
|
136
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
137
|
+
raise typer.Exit()
|
|
138
|
+
|
|
139
|
+
removed = diffmod.clear_history(username)
|
|
140
|
+
if removed:
|
|
141
|
+
console.print(f"[green]History cleared for '{username}'.[/green]")
|
|
142
|
+
else:
|
|
143
|
+
console.print(f"[dim]No history existed for '{username}'.[/dim]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
app()
|
argis/core.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The operational heart of Argis: loads site rules, runs concurrent async
|
|
2
|
+
HTTP checks, and applies detection rules to filter out false positives."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from argis.exceptions import SiteConfigError
|
|
13
|
+
from argis.utils.display import console, make_progress, print_found
|
|
14
|
+
from argis.utils.network import build_client, random_user_agent
|
|
15
|
+
|
|
16
|
+
# Generic markers that indicate a WAF/bot-challenge page rather than a real
|
|
17
|
+
# answer about account existence (e.g. Cloudflare's "Client Challenge",
|
|
18
|
+
# reCAPTCHA walls). These can return HTTP 200, so status-code rules alone
|
|
19
|
+
# would misread them as FOUND. Checked before any per-site rule runs.
|
|
20
|
+
_CHALLENGE_MARKERS = (
|
|
21
|
+
"client challenge",
|
|
22
|
+
"checking your browser",
|
|
23
|
+
"attention required",
|
|
24
|
+
"verify you are human",
|
|
25
|
+
"just a moment...",
|
|
26
|
+
"captcha",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArgisEngine:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
username: str,
|
|
34
|
+
*,
|
|
35
|
+
proxy: str | None = None,
|
|
36
|
+
use_tor: bool = False,
|
|
37
|
+
timeout: float = 7.0,
|
|
38
|
+
concurrency: int = 30,
|
|
39
|
+
sites_path: pathlib.Path | None = None,
|
|
40
|
+
):
|
|
41
|
+
self.username = username
|
|
42
|
+
self.proxy = proxy
|
|
43
|
+
self.use_tor = use_tor
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.sites = self._load_sites(sites_path)
|
|
46
|
+
self._semaphore = asyncio.Semaphore(concurrency)
|
|
47
|
+
|
|
48
|
+
def _load_sites(self, sites_path: pathlib.Path | None) -> dict:
|
|
49
|
+
"""Locate and load target site config relative to the package path."""
|
|
50
|
+
path = sites_path or (pathlib.Path(__file__).parent / "sites.json")
|
|
51
|
+
if not path.exists():
|
|
52
|
+
raise SiteConfigError(f"sites.json not found at {path}")
|
|
53
|
+
try:
|
|
54
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
55
|
+
sites = json.load(fh)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise SiteConfigError(f"sites.json is not valid JSON: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
for name, rules in sites.items():
|
|
60
|
+
if "url" not in rules or "error_type" not in rules:
|
|
61
|
+
raise SiteConfigError(
|
|
62
|
+
f"Site '{name}' is missing required 'url' or 'error_type' key"
|
|
63
|
+
)
|
|
64
|
+
return sites
|
|
65
|
+
|
|
66
|
+
async def check_platform(
|
|
67
|
+
self, client: httpx.AsyncClient, name: str, rules: dict
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Evaluate a single site against its detection rule. Never raises."""
|
|
70
|
+
target_url = rules["url"].format(self.username)
|
|
71
|
+
headers = {"User-Agent": random_user_agent()}
|
|
72
|
+
|
|
73
|
+
async with self._semaphore:
|
|
74
|
+
try:
|
|
75
|
+
response = await client.get(target_url, headers=headers)
|
|
76
|
+
except httpx.TooManyRedirects:
|
|
77
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
78
|
+
except httpx.TimeoutException:
|
|
79
|
+
return {"status": "TIMEOUT", "url": target_url}
|
|
80
|
+
except httpx.RequestError:
|
|
81
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
82
|
+
except Exception:
|
|
83
|
+
# Catches low-level transport/protocol errors that escape
|
|
84
|
+
# httpx's own exception hierarchy (e.g. a raw h2.ProtocolError
|
|
85
|
+
# from a connection torn down mid-handshake under high
|
|
86
|
+
# concurrency). A single misbehaving connection should never
|
|
87
|
+
# take down the whole scan's asyncio.gather.
|
|
88
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
89
|
+
|
|
90
|
+
# 429 / 403 usually mean a WAF or rate limiter intervened, not a
|
|
91
|
+
# legitimate answer about account existence.
|
|
92
|
+
if response.status_code in (403, 429):
|
|
93
|
+
return {"status": "BLOCKED", "url": target_url}
|
|
94
|
+
|
|
95
|
+
# Some WAFs (e.g. Cloudflare) serve a challenge page with a 200,
|
|
96
|
+
# which would otherwise be misread as a legitimate FOUND result.
|
|
97
|
+
lowered_text = response.text[:2000].lower()
|
|
98
|
+
if any(marker in lowered_text for marker in _CHALLENGE_MARKERS):
|
|
99
|
+
return {"status": "BLOCKED", "url": target_url}
|
|
100
|
+
|
|
101
|
+
error_type = rules["error_type"]
|
|
102
|
+
error_criteria = rules.get("error_criteria")
|
|
103
|
+
|
|
104
|
+
if error_type == "status_code":
|
|
105
|
+
if response.status_code == int(error_criteria):
|
|
106
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
107
|
+
elif error_type == "message":
|
|
108
|
+
if error_criteria and error_criteria in response.text:
|
|
109
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
110
|
+
elif error_type == "response_url":
|
|
111
|
+
if error_criteria and str(response.url).rstrip("/") == error_criteria.rstrip("/"):
|
|
112
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
113
|
+
|
|
114
|
+
if response.status_code == 200:
|
|
115
|
+
return {"status": "FOUND", "url": target_url}
|
|
116
|
+
|
|
117
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
118
|
+
|
|
119
|
+
async def run_scan(self, *, quiet: bool = False) -> dict[str, dict]:
|
|
120
|
+
"""Run all site checks concurrently with a live progress bar."""
|
|
121
|
+
results: dict[str, dict] = {}
|
|
122
|
+
|
|
123
|
+
async with build_client(
|
|
124
|
+
proxy=self.proxy, use_tor=self.use_tor, timeout=self.timeout
|
|
125
|
+
) as client:
|
|
126
|
+
if quiet:
|
|
127
|
+
tasks = [
|
|
128
|
+
self.check_platform(client, name, rules)
|
|
129
|
+
for name, rules in self.sites.items()
|
|
130
|
+
]
|
|
131
|
+
outcomes = await asyncio.gather(*tasks)
|
|
132
|
+
for (name, _), outcome in zip(self.sites.items(), outcomes):
|
|
133
|
+
results[name] = outcome
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
with make_progress() as progress:
|
|
137
|
+
task_id = progress.add_task(
|
|
138
|
+
"[yellow]Probing networks...", total=len(self.sites)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def run_one(name: str, rules: dict) -> None:
|
|
142
|
+
outcome = await self.check_platform(client, name, rules)
|
|
143
|
+
results[name] = outcome
|
|
144
|
+
if outcome["status"] == "FOUND":
|
|
145
|
+
progress.console.print(
|
|
146
|
+
f"[bold green][+][/bold green] [white]{name}:[/white] "
|
|
147
|
+
f"[underline cyan]{outcome['url']}[/underline cyan]"
|
|
148
|
+
)
|
|
149
|
+
progress.advance(task_id)
|
|
150
|
+
|
|
151
|
+
await asyncio.gather(
|
|
152
|
+
*(run_one(name, rules) for name, rules in self.sites.items())
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return results
|
argis/diff.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Persist scan history per-username as JSON and compute deltas between runs.
|
|
2
|
+
|
|
3
|
+
History layout: ~/.argis/history/<username>.json
|
|
4
|
+
[
|
|
5
|
+
{"timestamp": "2026-07-06T10:00:00+00:00", "results": {name: {"status": ..., "url": ...}}},
|
|
6
|
+
...
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
The most recent entry is always last in the list.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from argis.exceptions import HistoryError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def history_dir() -> Path:
|
|
22
|
+
"""Return (and create if needed) the directory holding all history files."""
|
|
23
|
+
directory = Path.home() / ".argis" / "history"
|
|
24
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return directory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_filename(username: str) -> str:
|
|
29
|
+
"""Sanitize a username into a filesystem-safe file stem."""
|
|
30
|
+
keep = "-_."
|
|
31
|
+
return "".join(c if c.isalnum() or c in keep else "_" for c in username) + ".json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def history_file(username: str) -> Path:
|
|
35
|
+
return history_dir() / _safe_filename(username)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_history(username: str) -> list[dict]:
|
|
39
|
+
"""Load all past scans for a username, oldest first. Empty list if none."""
|
|
40
|
+
path = history_file(username)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return []
|
|
43
|
+
try:
|
|
44
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
45
|
+
data = json.load(fh)
|
|
46
|
+
if not isinstance(data, list):
|
|
47
|
+
raise ValueError("history file is not a list")
|
|
48
|
+
return data
|
|
49
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
50
|
+
raise HistoryError(f"Could not read history for '{username}': {exc}") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_last_scan(username: str) -> dict | None:
|
|
54
|
+
history = load_history(username)
|
|
55
|
+
return history[-1] if history else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_scan(username: str, results: dict[str, dict], *, max_entries: int = 50) -> None:
|
|
59
|
+
"""Append a new scan snapshot to the username's history file.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
username: the target username.
|
|
63
|
+
results: mapping of platform name -> {"status": ..., "url": ...}.
|
|
64
|
+
max_entries: cap on retained history entries (oldest are dropped).
|
|
65
|
+
"""
|
|
66
|
+
history = load_history(username)
|
|
67
|
+
history.append(
|
|
68
|
+
{
|
|
69
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
"results": results,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
# Keep history bounded so the file doesn't grow unbounded over years of use.
|
|
74
|
+
history = history[-max_entries:]
|
|
75
|
+
|
|
76
|
+
path = history_file(username)
|
|
77
|
+
try:
|
|
78
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
79
|
+
json.dump(history, fh, indent=2)
|
|
80
|
+
except OSError as exc:
|
|
81
|
+
raise HistoryError(f"Could not write history for '{username}': {exc}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def clear_history(username: str) -> bool:
|
|
85
|
+
"""Delete a username's history file. Returns True if a file was removed."""
|
|
86
|
+
path = history_file(username)
|
|
87
|
+
if path.exists():
|
|
88
|
+
path.unlink()
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compute_diff(previous: dict[str, dict], current: dict[str, dict]) -> dict:
|
|
94
|
+
"""Compare two result snapshots and return added/removed/unchanged info.
|
|
95
|
+
|
|
96
|
+
A platform is "added" if it wasn't FOUND before but is FOUND now.
|
|
97
|
+
A platform is "removed" if it was FOUND before but is not FOUND now.
|
|
98
|
+
"""
|
|
99
|
+
added: list[tuple[str, str]] = []
|
|
100
|
+
removed: list[tuple[str, str]] = []
|
|
101
|
+
unchanged = 0
|
|
102
|
+
|
|
103
|
+
all_names = set(previous) | set(current)
|
|
104
|
+
for name in sorted(all_names):
|
|
105
|
+
prev_status = previous.get(name, {}).get("status")
|
|
106
|
+
curr_status = current.get(name, {}).get("status")
|
|
107
|
+
curr_url = current.get(name, {}).get("url", previous.get(name, {}).get("url", ""))
|
|
108
|
+
|
|
109
|
+
was_found = prev_status == "FOUND"
|
|
110
|
+
is_found = curr_status == "FOUND"
|
|
111
|
+
|
|
112
|
+
if is_found and not was_found:
|
|
113
|
+
added.append((name, curr_url))
|
|
114
|
+
elif was_found and not is_found:
|
|
115
|
+
removed.append((name, curr_url))
|
|
116
|
+
else:
|
|
117
|
+
unchanged += 1
|
|
118
|
+
|
|
119
|
+
return {"added": added, "removed": removed, "unchanged_count": unchanged}
|
argis/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exceptions used across Argis."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ArgisError(Exception):
|
|
5
|
+
"""Base class for all Argis-specific errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SiteConfigError(ArgisError):
|
|
9
|
+
"""Raised when sites.json is missing, malformed, or fails validation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SiteBlockedError(ArgisError):
|
|
13
|
+
"""Raised when a target site actively blocks or rate-limits the scanner."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, site_name: str, detail: str = ""):
|
|
16
|
+
self.site_name = site_name
|
|
17
|
+
self.detail = detail
|
|
18
|
+
message = f"{site_name} blocked the request"
|
|
19
|
+
if detail:
|
|
20
|
+
message += f": {detail}"
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HistoryError(ArgisError):
|
|
25
|
+
"""Raised when reading or writing scan history fails."""
|
argis/sites.json
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
{
|
|
2
|
+
"GitHub": {
|
|
3
|
+
"url": "https://github.com/{}",
|
|
4
|
+
"error_type": "status_code",
|
|
5
|
+
"error_criteria": 404,
|
|
6
|
+
"category": "coding"
|
|
7
|
+
},
|
|
8
|
+
"GitLab": {
|
|
9
|
+
"url": "https://gitlab.com/{}",
|
|
10
|
+
"error_type": "status_code",
|
|
11
|
+
"error_criteria": 404,
|
|
12
|
+
"category": "coding"
|
|
13
|
+
},
|
|
14
|
+
"Bitbucket": {
|
|
15
|
+
"url": "https://bitbucket.org/{}/",
|
|
16
|
+
"error_type": "status_code",
|
|
17
|
+
"error_criteria": 404,
|
|
18
|
+
"category": "coding"
|
|
19
|
+
},
|
|
20
|
+
"SourceForge": {
|
|
21
|
+
"url": "https://sourceforge.net/u/{}/",
|
|
22
|
+
"error_type": "status_code",
|
|
23
|
+
"error_criteria": 404,
|
|
24
|
+
"category": "coding"
|
|
25
|
+
},
|
|
26
|
+
"PyPI": {
|
|
27
|
+
"url": "https://pypi.org/user/{}/",
|
|
28
|
+
"error_type": "status_code",
|
|
29
|
+
"error_criteria": 404,
|
|
30
|
+
"category": "coding"
|
|
31
|
+
},
|
|
32
|
+
"npm": {
|
|
33
|
+
"url": "https://www.npmjs.com/~{}",
|
|
34
|
+
"error_type": "message",
|
|
35
|
+
"error_criteria": "Cannot GET",
|
|
36
|
+
"category": "coding"
|
|
37
|
+
},
|
|
38
|
+
"Docker Hub": {
|
|
39
|
+
"url": "https://hub.docker.com/u/{}",
|
|
40
|
+
"error_type": "status_code",
|
|
41
|
+
"error_criteria": 404,
|
|
42
|
+
"category": "coding"
|
|
43
|
+
},
|
|
44
|
+
"Replit": {
|
|
45
|
+
"url": "https://replit.com/@{}",
|
|
46
|
+
"error_type": "status_code",
|
|
47
|
+
"error_criteria": 404,
|
|
48
|
+
"category": "coding"
|
|
49
|
+
},
|
|
50
|
+
"CodePen": {
|
|
51
|
+
"url": "https://codepen.io/{}",
|
|
52
|
+
"error_type": "status_code",
|
|
53
|
+
"error_criteria": 404,
|
|
54
|
+
"category": "coding"
|
|
55
|
+
},
|
|
56
|
+
"Kaggle": {
|
|
57
|
+
"url": "https://www.kaggle.com/{}",
|
|
58
|
+
"error_type": "status_code",
|
|
59
|
+
"error_criteria": 404,
|
|
60
|
+
"category": "coding"
|
|
61
|
+
},
|
|
62
|
+
"Stack Overflow": {
|
|
63
|
+
"url": "https://stackoverflow.com/users/{}",
|
|
64
|
+
"error_type": "status_code",
|
|
65
|
+
"error_criteria": 404,
|
|
66
|
+
"category": "coding"
|
|
67
|
+
},
|
|
68
|
+
"HackerRank": {
|
|
69
|
+
"url": "https://www.hackerrank.com/{}",
|
|
70
|
+
"error_type": "message",
|
|
71
|
+
"error_criteria": "Page Not Found",
|
|
72
|
+
"category": "coding"
|
|
73
|
+
},
|
|
74
|
+
"HackerOne": {
|
|
75
|
+
"url": "https://hackerone.com/{}",
|
|
76
|
+
"error_type": "status_code",
|
|
77
|
+
"error_criteria": 404,
|
|
78
|
+
"category": "security"
|
|
79
|
+
},
|
|
80
|
+
"Bugcrowd": {
|
|
81
|
+
"url": "https://bugcrowd.com/{}",
|
|
82
|
+
"error_type": "status_code",
|
|
83
|
+
"error_criteria": 404,
|
|
84
|
+
"category": "security"
|
|
85
|
+
},
|
|
86
|
+
"Keybase": {
|
|
87
|
+
"url": "https://keybase.io/{}",
|
|
88
|
+
"error_type": "status_code",
|
|
89
|
+
"error_criteria": 404,
|
|
90
|
+
"category": "security"
|
|
91
|
+
},
|
|
92
|
+
"Reddit": {
|
|
93
|
+
"url": "https://www.reddit.com/user/{}",
|
|
94
|
+
"error_type": "status_code",
|
|
95
|
+
"error_criteria": 404,
|
|
96
|
+
"category": "social"
|
|
97
|
+
},
|
|
98
|
+
"X (Twitter)": {
|
|
99
|
+
"url": "https://x.com/{}",
|
|
100
|
+
"error_type": "message",
|
|
101
|
+
"error_criteria": "This account doesn’t exist",
|
|
102
|
+
"category": "social"
|
|
103
|
+
},
|
|
104
|
+
"Instagram": {
|
|
105
|
+
"url": "https://www.instagram.com/{}/",
|
|
106
|
+
"error_type": "status_code",
|
|
107
|
+
"error_criteria": 404,
|
|
108
|
+
"category": "social"
|
|
109
|
+
},
|
|
110
|
+
"Facebook": {
|
|
111
|
+
"url": "https://www.facebook.com/{}",
|
|
112
|
+
"error_type": "message",
|
|
113
|
+
"error_criteria": "This content isn't available",
|
|
114
|
+
"category": "social"
|
|
115
|
+
},
|
|
116
|
+
"TikTok": {
|
|
117
|
+
"url": "https://www.tiktok.com/@{}",
|
|
118
|
+
"error_type": "message",
|
|
119
|
+
"error_criteria": "Couldn't find this account",
|
|
120
|
+
"category": "social"
|
|
121
|
+
},
|
|
122
|
+
"Snapchat": {
|
|
123
|
+
"url": "https://www.snapchat.com/add/{}",
|
|
124
|
+
"error_type": "status_code",
|
|
125
|
+
"error_criteria": 404,
|
|
126
|
+
"category": "social"
|
|
127
|
+
},
|
|
128
|
+
"Pinterest": {
|
|
129
|
+
"url": "https://www.pinterest.com/{}/",
|
|
130
|
+
"error_type": "status_code",
|
|
131
|
+
"error_criteria": 404,
|
|
132
|
+
"category": "social"
|
|
133
|
+
},
|
|
134
|
+
"Tumblr": {
|
|
135
|
+
"url": "https://{}.tumblr.com/",
|
|
136
|
+
"error_type": "status_code",
|
|
137
|
+
"error_criteria": 404,
|
|
138
|
+
"category": "social"
|
|
139
|
+
},
|
|
140
|
+
"LinkedIn": {
|
|
141
|
+
"url": "https://www.linkedin.com/in/{}",
|
|
142
|
+
"error_type": "status_code",
|
|
143
|
+
"error_criteria": 404,
|
|
144
|
+
"category": "social"
|
|
145
|
+
},
|
|
146
|
+
"Mastodon (mastodon.social)": {
|
|
147
|
+
"url": "https://mastodon.social/@{}",
|
|
148
|
+
"error_type": "status_code",
|
|
149
|
+
"error_criteria": 404,
|
|
150
|
+
"category": "social"
|
|
151
|
+
},
|
|
152
|
+
"VK": {
|
|
153
|
+
"url": "https://vk.com/{}",
|
|
154
|
+
"error_type": "message",
|
|
155
|
+
"error_criteria": "This page has been deleted",
|
|
156
|
+
"category": "social"
|
|
157
|
+
},
|
|
158
|
+
"Quora": {
|
|
159
|
+
"url": "https://www.quora.com/profile/{}",
|
|
160
|
+
"error_type": "message",
|
|
161
|
+
"error_criteria": "Page Not Found",
|
|
162
|
+
"category": "social"
|
|
163
|
+
},
|
|
164
|
+
"YouTube": {
|
|
165
|
+
"url": "https://www.youtube.com/@{}",
|
|
166
|
+
"error_type": "status_code",
|
|
167
|
+
"error_criteria": 404,
|
|
168
|
+
"category": "media"
|
|
169
|
+
},
|
|
170
|
+
"Twitch": {
|
|
171
|
+
"url": "https://www.twitch.tv/{}",
|
|
172
|
+
"error_type": "message",
|
|
173
|
+
"error_criteria": "Sorry. Unless you've got a time machine",
|
|
174
|
+
"category": "media"
|
|
175
|
+
},
|
|
176
|
+
"Vimeo": {
|
|
177
|
+
"url": "https://vimeo.com/{}",
|
|
178
|
+
"error_type": "status_code",
|
|
179
|
+
"error_criteria": 404,
|
|
180
|
+
"category": "media"
|
|
181
|
+
},
|
|
182
|
+
"SoundCloud": {
|
|
183
|
+
"url": "https://soundcloud.com/{}",
|
|
184
|
+
"error_type": "status_code",
|
|
185
|
+
"error_criteria": 404,
|
|
186
|
+
"category": "media"
|
|
187
|
+
},
|
|
188
|
+
"Spotify": {
|
|
189
|
+
"url": "https://open.spotify.com/user/{}",
|
|
190
|
+
"error_type": "status_code",
|
|
191
|
+
"error_criteria": 404,
|
|
192
|
+
"category": "media"
|
|
193
|
+
},
|
|
194
|
+
"Last.fm": {
|
|
195
|
+
"url": "https://www.last.fm/user/{}",
|
|
196
|
+
"error_type": "status_code",
|
|
197
|
+
"error_criteria": 404,
|
|
198
|
+
"category": "media"
|
|
199
|
+
},
|
|
200
|
+
"Flickr": {
|
|
201
|
+
"url": "https://www.flickr.com/people/{}",
|
|
202
|
+
"error_type": "status_code",
|
|
203
|
+
"error_criteria": 404,
|
|
204
|
+
"category": "media"
|
|
205
|
+
},
|
|
206
|
+
"DeviantArt": {
|
|
207
|
+
"url": "https://{}.deviantart.com/",
|
|
208
|
+
"error_type": "status_code",
|
|
209
|
+
"error_criteria": 404,
|
|
210
|
+
"category": "media"
|
|
211
|
+
},
|
|
212
|
+
"Behance": {
|
|
213
|
+
"url": "https://www.behance.net/{}",
|
|
214
|
+
"error_type": "status_code",
|
|
215
|
+
"error_criteria": 404,
|
|
216
|
+
"category": "media"
|
|
217
|
+
},
|
|
218
|
+
"Dribbble": {
|
|
219
|
+
"url": "https://dribbble.com/{}",
|
|
220
|
+
"error_type": "status_code",
|
|
221
|
+
"error_criteria": 404,
|
|
222
|
+
"category": "media"
|
|
223
|
+
},
|
|
224
|
+
"500px": {
|
|
225
|
+
"url": "https://500px.com/p/{}",
|
|
226
|
+
"error_type": "status_code",
|
|
227
|
+
"error_criteria": 404,
|
|
228
|
+
"category": "media"
|
|
229
|
+
},
|
|
230
|
+
"Medium": {
|
|
231
|
+
"url": "https://medium.com/@{}",
|
|
232
|
+
"error_type": "response_url",
|
|
233
|
+
"error_criteria": "https://medium.com/404",
|
|
234
|
+
"category": "blogging"
|
|
235
|
+
},
|
|
236
|
+
"Dev.to": {
|
|
237
|
+
"url": "https://dev.to/{}",
|
|
238
|
+
"error_type": "status_code",
|
|
239
|
+
"error_criteria": 404,
|
|
240
|
+
"category": "blogging"
|
|
241
|
+
},
|
|
242
|
+
"Hashnode": {
|
|
243
|
+
"url": "https://hashnode.com/@{}",
|
|
244
|
+
"error_type": "status_code",
|
|
245
|
+
"error_criteria": 404,
|
|
246
|
+
"category": "blogging"
|
|
247
|
+
},
|
|
248
|
+
"Substack": {
|
|
249
|
+
"url": "https://{}.substack.com/",
|
|
250
|
+
"error_type": "status_code",
|
|
251
|
+
"error_criteria": 404,
|
|
252
|
+
"category": "blogging"
|
|
253
|
+
},
|
|
254
|
+
"WordPress": {
|
|
255
|
+
"url": "https://{}.wordpress.com/",
|
|
256
|
+
"error_type": "status_code",
|
|
257
|
+
"error_criteria": 404,
|
|
258
|
+
"category": "blogging"
|
|
259
|
+
},
|
|
260
|
+
"Blogger": {
|
|
261
|
+
"url": "https://{}.blogspot.com/",
|
|
262
|
+
"error_type": "status_code",
|
|
263
|
+
"error_criteria": 404,
|
|
264
|
+
"category": "blogging"
|
|
265
|
+
},
|
|
266
|
+
"Wattpad": {
|
|
267
|
+
"url": "https://www.wattpad.com/user/{}",
|
|
268
|
+
"error_type": "status_code",
|
|
269
|
+
"error_criteria": 404,
|
|
270
|
+
"category": "blogging"
|
|
271
|
+
},
|
|
272
|
+
"Goodreads": {
|
|
273
|
+
"url": "https://www.goodreads.com/{}",
|
|
274
|
+
"error_type": "status_code",
|
|
275
|
+
"error_criteria": 404,
|
|
276
|
+
"category": "lifestyle"
|
|
277
|
+
},
|
|
278
|
+
"Letterboxd": {
|
|
279
|
+
"url": "https://letterboxd.com/{}/",
|
|
280
|
+
"error_type": "status_code",
|
|
281
|
+
"error_criteria": 404,
|
|
282
|
+
"category": "lifestyle"
|
|
283
|
+
},
|
|
284
|
+
"MyAnimeList": {
|
|
285
|
+
"url": "https://myanimelist.net/profile/{}",
|
|
286
|
+
"error_type": "status_code",
|
|
287
|
+
"error_criteria": 404,
|
|
288
|
+
"category": "lifestyle"
|
|
289
|
+
},
|
|
290
|
+
"Chess.com": {
|
|
291
|
+
"url": "https://www.chess.com/member/{}",
|
|
292
|
+
"error_type": "status_code",
|
|
293
|
+
"error_criteria": 404,
|
|
294
|
+
"category": "lifestyle"
|
|
295
|
+
},
|
|
296
|
+
"Lichess": {
|
|
297
|
+
"url": "https://lichess.org/@/{}",
|
|
298
|
+
"error_type": "status_code",
|
|
299
|
+
"error_criteria": 404,
|
|
300
|
+
"category": "lifestyle"
|
|
301
|
+
},
|
|
302
|
+
"Steam": {
|
|
303
|
+
"url": "https://steamcommunity.com/id/{}",
|
|
304
|
+
"error_type": "message",
|
|
305
|
+
"error_criteria": "The specified profile could not be found",
|
|
306
|
+
"category": "gaming"
|
|
307
|
+
},
|
|
308
|
+
"Xbox Gamertag": {
|
|
309
|
+
"url": "https://xboxgamertag.com/search/{}",
|
|
310
|
+
"error_type": "message",
|
|
311
|
+
"error_criteria": "This profile is currently unavailable",
|
|
312
|
+
"category": "gaming"
|
|
313
|
+
},
|
|
314
|
+
"Itch.io": {
|
|
315
|
+
"url": "https://{}.itch.io/",
|
|
316
|
+
"error_type": "status_code",
|
|
317
|
+
"error_criteria": 404,
|
|
318
|
+
"category": "gaming"
|
|
319
|
+
},
|
|
320
|
+
"Patreon": {
|
|
321
|
+
"url": "https://www.patreon.com/{}",
|
|
322
|
+
"error_type": "status_code",
|
|
323
|
+
"error_criteria": 404,
|
|
324
|
+
"category": "funding"
|
|
325
|
+
},
|
|
326
|
+
"Ko-fi": {
|
|
327
|
+
"url": "https://ko-fi.com/{}",
|
|
328
|
+
"error_type": "message",
|
|
329
|
+
"error_criteria": "This page is not available",
|
|
330
|
+
"category": "funding"
|
|
331
|
+
},
|
|
332
|
+
"Buy Me a Coffee": {
|
|
333
|
+
"url": "https://www.buymeacoffee.com/{}",
|
|
334
|
+
"error_type": "status_code",
|
|
335
|
+
"error_criteria": 404,
|
|
336
|
+
"category": "funding"
|
|
337
|
+
},
|
|
338
|
+
"GoFundMe": {
|
|
339
|
+
"url": "https://www.gofundme.com/f/{}",
|
|
340
|
+
"error_type": "status_code",
|
|
341
|
+
"error_criteria": 404,
|
|
342
|
+
"category": "funding"
|
|
343
|
+
},
|
|
344
|
+
"Product Hunt": {
|
|
345
|
+
"url": "https://www.producthunt.com/@{}",
|
|
346
|
+
"error_type": "status_code",
|
|
347
|
+
"error_criteria": 404,
|
|
348
|
+
"category": "startup"
|
|
349
|
+
},
|
|
350
|
+
"AngelList (Wellfound)": {
|
|
351
|
+
"url": "https://wellfound.com/u/{}",
|
|
352
|
+
"error_type": "status_code",
|
|
353
|
+
"error_criteria": 404,
|
|
354
|
+
"category": "startup"
|
|
355
|
+
},
|
|
356
|
+
"Gravatar": {
|
|
357
|
+
"url": "https://gravatar.com/{}",
|
|
358
|
+
"error_type": "message",
|
|
359
|
+
"error_criteria": "profile isn't available",
|
|
360
|
+
"category": "identity"
|
|
361
|
+
},
|
|
362
|
+
"About.me": {
|
|
363
|
+
"url": "https://about.me/{}",
|
|
364
|
+
"error_type": "status_code",
|
|
365
|
+
"error_criteria": 404,
|
|
366
|
+
"category": "identity"
|
|
367
|
+
},
|
|
368
|
+
"Linktree": {
|
|
369
|
+
"url": "https://linktr.ee/{}",
|
|
370
|
+
"error_type": "message",
|
|
371
|
+
"error_criteria": "This page is unavailable",
|
|
372
|
+
"category": "identity"
|
|
373
|
+
},
|
|
374
|
+
"Imgur": {
|
|
375
|
+
"url": "https://imgur.com/user/{}",
|
|
376
|
+
"error_type": "status_code",
|
|
377
|
+
"error_criteria": 404,
|
|
378
|
+
"category": "media"
|
|
379
|
+
},
|
|
380
|
+
"Telegram": {
|
|
381
|
+
"url": "https://t.me/{}",
|
|
382
|
+
"error_type": "message",
|
|
383
|
+
"error_criteria": "If you have Telegram, you can contact",
|
|
384
|
+
"category": "messaging"
|
|
385
|
+
},
|
|
386
|
+
"SlideShare": {
|
|
387
|
+
"url": "https://www.slideshare.net/{}",
|
|
388
|
+
"error_type": "status_code",
|
|
389
|
+
"error_criteria": 404,
|
|
390
|
+
"category": "docs"
|
|
391
|
+
},
|
|
392
|
+
"Issuu": {
|
|
393
|
+
"url": "https://issuu.com/{}",
|
|
394
|
+
"error_type": "status_code",
|
|
395
|
+
"error_criteria": 404,
|
|
396
|
+
"category": "docs"
|
|
397
|
+
},
|
|
398
|
+
"Codeforces": {
|
|
399
|
+
"url": "https://codeforces.com/profile/{}",
|
|
400
|
+
"error_type": "status_code",
|
|
401
|
+
"error_criteria": 404,
|
|
402
|
+
"category": "coding"
|
|
403
|
+
},
|
|
404
|
+
"LeetCode": {
|
|
405
|
+
"url": "https://leetcode.com/{}/",
|
|
406
|
+
"error_type": "status_code",
|
|
407
|
+
"error_criteria": 404,
|
|
408
|
+
"category": "coding"
|
|
409
|
+
},
|
|
410
|
+
"Codewars": {
|
|
411
|
+
"url": "https://www.codewars.com/users/{}",
|
|
412
|
+
"error_type": "status_code",
|
|
413
|
+
"error_criteria": 404,
|
|
414
|
+
"category": "coding"
|
|
415
|
+
},
|
|
416
|
+
"Hacker News": {
|
|
417
|
+
"url": "https://news.ycombinator.com/user?id={}",
|
|
418
|
+
"error_type": "message",
|
|
419
|
+
"error_criteria": "No such user.",
|
|
420
|
+
"category": "coding"
|
|
421
|
+
},
|
|
422
|
+
"Sourcehut": {
|
|
423
|
+
"url": "https://sr.ht/~{}",
|
|
424
|
+
"error_type": "status_code",
|
|
425
|
+
"error_criteria": 404,
|
|
426
|
+
"category": "coding"
|
|
427
|
+
},
|
|
428
|
+
"osu!": {
|
|
429
|
+
"url": "https://osu.ppy.sh/users/{}",
|
|
430
|
+
"error_type": "status_code",
|
|
431
|
+
"error_criteria": 404,
|
|
432
|
+
"category": "gaming"
|
|
433
|
+
},
|
|
434
|
+
"Kongregate": {
|
|
435
|
+
"url": "https://www.kongregate.com/accounts/{}",
|
|
436
|
+
"error_type": "status_code",
|
|
437
|
+
"error_criteria": 404,
|
|
438
|
+
"category": "gaming"
|
|
439
|
+
},
|
|
440
|
+
"Newgrounds": {
|
|
441
|
+
"url": "https://{}.newgrounds.com/",
|
|
442
|
+
"error_type": "status_code",
|
|
443
|
+
"error_criteria": 404,
|
|
444
|
+
"category": "gaming"
|
|
445
|
+
},
|
|
446
|
+
"Bandcamp": {
|
|
447
|
+
"url": "https://{}.bandcamp.com/",
|
|
448
|
+
"error_type": "status_code",
|
|
449
|
+
"error_criteria": 404,
|
|
450
|
+
"category": "media"
|
|
451
|
+
},
|
|
452
|
+
"Mixcloud": {
|
|
453
|
+
"url": "https://www.mixcloud.com/{}/",
|
|
454
|
+
"error_type": "status_code",
|
|
455
|
+
"error_criteria": 404,
|
|
456
|
+
"category": "media"
|
|
457
|
+
},
|
|
458
|
+
"Dailymotion": {
|
|
459
|
+
"url": "https://www.dailymotion.com/{}",
|
|
460
|
+
"error_type": "status_code",
|
|
461
|
+
"error_criteria": 404,
|
|
462
|
+
"category": "media"
|
|
463
|
+
},
|
|
464
|
+
"ArtStation": {
|
|
465
|
+
"url": "https://www.artstation.com/{}",
|
|
466
|
+
"error_type": "status_code",
|
|
467
|
+
"error_criteria": 404,
|
|
468
|
+
"category": "media"
|
|
469
|
+
},
|
|
470
|
+
"VSCO": {
|
|
471
|
+
"url": "https://vsco.co/{}",
|
|
472
|
+
"error_type": "status_code",
|
|
473
|
+
"error_criteria": 404,
|
|
474
|
+
"category": "media"
|
|
475
|
+
},
|
|
476
|
+
"Pastebin": {
|
|
477
|
+
"url": "https://pastebin.com/u/{}",
|
|
478
|
+
"error_type": "status_code",
|
|
479
|
+
"error_criteria": 404,
|
|
480
|
+
"category": "docs"
|
|
481
|
+
},
|
|
482
|
+
"Fiverr": {
|
|
483
|
+
"url": "https://www.fiverr.com/{}",
|
|
484
|
+
"error_type": "status_code",
|
|
485
|
+
"error_criteria": 404,
|
|
486
|
+
"category": "startup"
|
|
487
|
+
},
|
|
488
|
+
"Open Collective": {
|
|
489
|
+
"url": "https://opencollective.com/{}",
|
|
490
|
+
"error_type": "status_code",
|
|
491
|
+
"error_criteria": 404,
|
|
492
|
+
"category": "funding"
|
|
493
|
+
},
|
|
494
|
+
"Untappd": {
|
|
495
|
+
"url": "https://untappd.com/user/{}",
|
|
496
|
+
"error_type": "status_code",
|
|
497
|
+
"error_criteria": 404,
|
|
498
|
+
"category": "lifestyle"
|
|
499
|
+
},
|
|
500
|
+
"Ravelry": {
|
|
501
|
+
"url": "https://www.ravelry.com/people/{}",
|
|
502
|
+
"error_type": "status_code",
|
|
503
|
+
"error_criteria": 404,
|
|
504
|
+
"category": "lifestyle"
|
|
505
|
+
}
|
|
506
|
+
}
|
argis/utils/__init__.py
ADDED
|
File without changes
|
argis/utils/display.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Rich-based terminal UI components: progress bars, tables, diff views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
BarColumn,
|
|
9
|
+
MofNCompleteColumn,
|
|
10
|
+
Progress,
|
|
11
|
+
SpinnerColumn,
|
|
12
|
+
TextColumn,
|
|
13
|
+
)
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
STATUS_STYLES = {
|
|
19
|
+
"FOUND": "bold green",
|
|
20
|
+
"NOT_FOUND": "dim red",
|
|
21
|
+
"UNKNOWN": "yellow",
|
|
22
|
+
"TIMEOUT": "yellow",
|
|
23
|
+
"BLOCKED": "bold magenta",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_progress() -> Progress:
|
|
28
|
+
"""Build the live progress bar used while scanning platforms."""
|
|
29
|
+
return Progress(
|
|
30
|
+
SpinnerColumn(),
|
|
31
|
+
TextColumn("[progress.description]{task.description}"),
|
|
32
|
+
BarColumn(),
|
|
33
|
+
MofNCompleteColumn(),
|
|
34
|
+
console=console,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def print_banner(username: str) -> None:
|
|
39
|
+
console.print(f"[bold white]\U0001f441\ufe0f Argis Engine[/bold white] initializing...")
|
|
40
|
+
console.print(f"Targeting handle: [bold cyan]@{username}[/bold cyan]\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def print_found(name: str, url: str) -> None:
|
|
44
|
+
console.print(f"[bold green][+][/bold green] [white]{name}:[/white] "
|
|
45
|
+
f"[underline cyan]{url}[/underline cyan]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_results_table(results: dict[str, dict], username: str) -> None:
|
|
49
|
+
"""Render a full results table (name, status, url)."""
|
|
50
|
+
table = Table(title=f"Argis scan results for @{username}", show_lines=False)
|
|
51
|
+
table.add_column("Platform", style="white")
|
|
52
|
+
table.add_column("Status")
|
|
53
|
+
table.add_column("URL", style="cyan", overflow="fold")
|
|
54
|
+
|
|
55
|
+
for name, info in sorted(results.items()):
|
|
56
|
+
status = info["status"]
|
|
57
|
+
style = STATUS_STYLES.get(status, "white")
|
|
58
|
+
table.add_row(name, f"[{style}]{status}[/{style}]", info["url"])
|
|
59
|
+
|
|
60
|
+
console.print(table)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_summary(results: dict[str, dict]) -> None:
|
|
64
|
+
found = sum(1 for r in results.values() if r["status"] == "FOUND")
|
|
65
|
+
total = len(results)
|
|
66
|
+
console.print(
|
|
67
|
+
Panel.fit(
|
|
68
|
+
f"[bold green]{found}[/bold green] / {total} platforms show an "
|
|
69
|
+
f"active profile",
|
|
70
|
+
title="Summary",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def print_diff(diff: dict) -> None:
|
|
76
|
+
"""Render a diff report: newly found, newly gone, unchanged counts."""
|
|
77
|
+
table = Table(title="Diff since last scan")
|
|
78
|
+
table.add_column("Change", style="white")
|
|
79
|
+
table.add_column("Platform")
|
|
80
|
+
table.add_column("URL", style="cyan", overflow="fold")
|
|
81
|
+
|
|
82
|
+
for name, url in diff.get("added", []):
|
|
83
|
+
table.add_row("[bold green][+] REGISTERED[/bold green]", name, url)
|
|
84
|
+
for name, url in diff.get("removed", []):
|
|
85
|
+
table.add_row("[bold red][-] DELETED[/bold red]", name, url)
|
|
86
|
+
|
|
87
|
+
if not diff.get("added") and not diff.get("removed"):
|
|
88
|
+
console.print("[dim]No changes detected since the last scan.[/dim]")
|
|
89
|
+
else:
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
unchanged = diff.get("unchanged_count", 0)
|
|
93
|
+
console.print(f"[dim]{unchanged} platform(s) unchanged.[/dim]")
|
argis/utils/export.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Serialize scan results to csv, json, or markdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_json(results: dict[str, dict]) -> str:
|
|
12
|
+
return json.dumps(results, indent=2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_csv(results: dict[str, dict]) -> str:
|
|
16
|
+
buffer = StringIO()
|
|
17
|
+
writer = csv.writer(buffer)
|
|
18
|
+
writer.writerow(["platform", "status", "url"])
|
|
19
|
+
for name, info in sorted(results.items()):
|
|
20
|
+
writer.writerow([name, info["status"], info["url"]])
|
|
21
|
+
return buffer.getvalue()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_markdown(results: dict[str, dict], username: str) -> str:
|
|
25
|
+
lines = [f"# Argis scan results for `@{username}`", "", "| Platform | Status | URL |", "|---|---|---|"]
|
|
26
|
+
for name, info in sorted(results.items()):
|
|
27
|
+
lines.append(f"| {name} | {info['status']} | {info['url']} |")
|
|
28
|
+
return "\n".join(lines) + "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
FORMATTERS = {
|
|
32
|
+
"json": lambda results, username: to_json(results),
|
|
33
|
+
"csv": lambda results, username: to_csv(results),
|
|
34
|
+
"markdown": to_markdown,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def export_results(results: dict[str, dict], username: str, fmt: str, out_path: Path) -> Path:
|
|
39
|
+
formatter = FORMATTERS.get(fmt)
|
|
40
|
+
if formatter is None:
|
|
41
|
+
raise ValueError(f"Unsupported export format: {fmt}")
|
|
42
|
+
content = formatter(results, username)
|
|
43
|
+
out_path.write_text(content, encoding="utf-8")
|
|
44
|
+
return out_path
|
argis/utils/network.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""HTTP client construction, user-agent rotation, and proxy/Tor routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
# A small rotation pool. Real browser UAs reduce the odds of a WAF (e.g.
|
|
10
|
+
# Cloudflare) flagging the scan as bot traffic.
|
|
11
|
+
USER_AGENTS = [
|
|
12
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
13
|
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
14
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 "
|
|
15
|
+
"(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
16
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
17
|
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
18
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) "
|
|
19
|
+
"Gecko/20100101 Firefox/125.0",
|
|
20
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) "
|
|
21
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 "
|
|
22
|
+
"Safari/604.1",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
TOR_PROXY_URL = "socks5://127.0.0.1:9050"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def random_user_agent() -> str:
|
|
29
|
+
"""Return a random desktop/mobile User-Agent string."""
|
|
30
|
+
return random.choice(USER_AGENTS)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_client(
|
|
34
|
+
*,
|
|
35
|
+
proxy: str | None = None,
|
|
36
|
+
use_tor: bool = False,
|
|
37
|
+
timeout: float = 7.0,
|
|
38
|
+
http2: bool = False,
|
|
39
|
+
) -> httpx.AsyncClient:
|
|
40
|
+
"""Construct a configured httpx.AsyncClient.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
proxy: Explicit proxy URL (e.g. "socks5://127.0.0.1:9050" or
|
|
44
|
+
"http://user:pass@host:port"). Takes precedence over use_tor.
|
|
45
|
+
use_tor: If True and no explicit proxy given, route through a local
|
|
46
|
+
Tor SOCKS5 proxy (assumes Tor is running on the default port).
|
|
47
|
+
timeout: Per-request timeout in seconds.
|
|
48
|
+
http2: Enable HTTP/2 multiplexing support.
|
|
49
|
+
"""
|
|
50
|
+
proxy_url = proxy or (TOR_PROXY_URL if use_tor else None)
|
|
51
|
+
|
|
52
|
+
kwargs: dict = {
|
|
53
|
+
"http2": http2,
|
|
54
|
+
"timeout": httpx.Timeout(timeout),
|
|
55
|
+
"follow_redirects": True,
|
|
56
|
+
}
|
|
57
|
+
if proxy_url:
|
|
58
|
+
kwargs["proxy"] = proxy_url
|
|
59
|
+
|
|
60
|
+
return httpx.AsyncClient(**kwargs)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous username hunter with historical diff tracking across social platforms.
|
|
5
|
+
Author: mohilisop
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: async,cli,osint,security,username
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx[http2]>=0.27.0
|
|
10
|
+
Requires-Dist: rich>=13.7.0
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Argis 👁️
|
|
18
|
+
|
|
19
|
+
**The all-seeing username scanner.**
|
|
20
|
+
|
|
21
|
+
Argis hunts down a username across dozens of platforms concurrently, tells
|
|
22
|
+
you where it's registered, and — unlike most tools in this space — tracks
|
|
23
|
+
how that footprint *changes* over time.
|
|
24
|
+
|
|
25
|
+
Named after Argus, the hundred-eyed giant of Greek myth: one scan, every
|
|
26
|
+
platform, watched at once.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Async everything.** Built on `httpx` + `asyncio`; scans 80+ sites in
|
|
31
|
+
parallel instead of one at a time.
|
|
32
|
+
- **Diff engine.** `--diff` compares the current scan against your last
|
|
33
|
+
saved run and shows exactly what got registered or deleted.
|
|
34
|
+
- **False-positive resistant.** Detection rules per site (status code,
|
|
35
|
+
page-text match, or redirect-URL match) instead of blindly trusting a
|
|
36
|
+
200 OK.
|
|
37
|
+
- **Pretty terminal UI.** Live progress bar and color-coded results via
|
|
38
|
+
`rich`.
|
|
39
|
+
- **Exportable.** `--export csv|json|markdown` for piping into other tools.
|
|
40
|
+
- **Proxy / Tor support.** Route scans through a proxy or local Tor.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# From source, editable (for development):
|
|
46
|
+
pip install -e .
|
|
47
|
+
|
|
48
|
+
# Or with pipx (recommended once published):
|
|
49
|
+
pipx install argis
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires Python 3.10+.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Basic scan
|
|
58
|
+
argis scan john_doe
|
|
59
|
+
|
|
60
|
+
# Scan and compare against the last saved run
|
|
61
|
+
argis scan john_doe --diff
|
|
62
|
+
|
|
63
|
+
# Don't save this run to history
|
|
64
|
+
argis scan john_doe --no-save
|
|
65
|
+
|
|
66
|
+
# Export results
|
|
67
|
+
argis scan john_doe --export markdown -o john_doe_report.md
|
|
68
|
+
|
|
69
|
+
# Route through Tor
|
|
70
|
+
argis scan john_doe --tor
|
|
71
|
+
|
|
72
|
+
# View past scans
|
|
73
|
+
argis history john_doe
|
|
74
|
+
|
|
75
|
+
# Wipe saved history
|
|
76
|
+
argis clear-history john_doe
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## How detection works
|
|
80
|
+
|
|
81
|
+
Each entry in `src/argis/sites.json` defines a URL template plus a rule for
|
|
82
|
+
recognizing a "not found" response:
|
|
83
|
+
|
|
84
|
+
| `error_type` | Meaning |
|
|
85
|
+
|----------------|-------------------------------------------------------------------|
|
|
86
|
+
| `status_code` | Account doesn't exist if the response status matches `error_criteria` |
|
|
87
|
+
| `message` | Account doesn't exist if `error_criteria` text appears in the HTML |
|
|
88
|
+
| `response_url` | Account doesn't exist if the final (post-redirect) URL matches |
|
|
89
|
+
|
|
90
|
+
Add your own targets by editing `sites.json` — no code changes required.
|
|
91
|
+
|
|
92
|
+
## History storage
|
|
93
|
+
|
|
94
|
+
Scan history is stored per-username as JSON at
|
|
95
|
+
`~/.argis/history/<username>.json`. Each file holds a bounded list of past
|
|
96
|
+
snapshots (newest last), which is what `--diff` and `argis history` read
|
|
97
|
+
from.
|
|
98
|
+
|
|
99
|
+
## Project layout
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
argis/
|
|
103
|
+
├── pyproject.toml
|
|
104
|
+
├── src/argis/
|
|
105
|
+
│ ├── cli.py # typer commands
|
|
106
|
+
│ ├── core.py # async scanning engine
|
|
107
|
+
│ ├── diff.py # history storage + diff computation
|
|
108
|
+
│ ├── exceptions.py
|
|
109
|
+
│ ├── sites.json # target platforms + detection rules
|
|
110
|
+
│ └── utils/
|
|
111
|
+
│ ├── display.py # rich UI
|
|
112
|
+
│ ├── network.py # httpx client, UA rotation, proxy/Tor
|
|
113
|
+
│ └── export.py # csv/json/markdown export
|
|
114
|
+
└── tests/
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Disclaimer
|
|
118
|
+
|
|
119
|
+
Use responsibly. Only look up usernames you have a legitimate reason to
|
|
120
|
+
investigate, and respect the terms of service of the sites you query.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
argis/__init__.py,sha256=EYtv235kanrA-_viKjVuSrWpJrysBzeWl3rlryv0Yhc,72
|
|
2
|
+
argis/__main__.py,sha256=nbC5WGa8tJJHmgEl_KaVtcTMPtWj5UCwwsNkjxm65ic,64
|
|
3
|
+
argis/cli.py,sha256=C7EhNsHyQuAw493Ab9bbt32G51IRsUjVwFFCtSoTweA,5158
|
|
4
|
+
argis/core.py,sha256=IxXbqMIl4iy5-TugHV4Yj7b_AWNYeRsw7lgaghMsp6c,6346
|
|
5
|
+
argis/diff.py,sha256=iB6boQWIgljrzsoWmD0yQQECybf6prWgApVkGEQskpM,3922
|
|
6
|
+
argis/exceptions.py,sha256=RLSasG9qZIxd0F4z4sJgn99qC6pyOV1cj0REZ7Pt02o,719
|
|
7
|
+
argis/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
argis/utils/display.py,sha256=jKxemFKxwikh8R1o316MyE34MxQlpCuMkvHoozeTHdk,2945
|
|
9
|
+
argis/utils/export.py,sha256=DLdqh0kJQoQJVIJ9Y3PpfMIugzGC7Pdz6HCURd8V-Lw,1366
|
|
10
|
+
argis/utils/network.py,sha256=d3zT99eqwBLeCScXZclyZD3OxoGl7ER62pzEIdzySN4,1987
|
|
11
|
+
argis/sites.json,sha256=gvzqkeZq36QeDsZiuZw7SdQwY5ix9kNjy-HGWWPVhlE,12742
|
|
12
|
+
argis-0.1.0.dist-info/METADATA,sha256=a1HtHIatgsv2lTWw9wTDFPGdM9PVbxR3uE0tRtns15A,3678
|
|
13
|
+
argis-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
argis-0.1.0.dist-info/entry_points.txt,sha256=MjCc3_UW3cgb8Ja6PSMKQvz5Ulv1dk4NLCg1pn_xNfA,40
|
|
15
|
+
argis-0.1.0.dist-info/RECORD,,
|