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 ADDED
@@ -0,0 +1,3 @@
1
+ """Argis — the all-seeing username scanner."""
2
+
3
+ __version__ = "0.1.0"
argis/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from argis.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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
+ }
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ argis = argis.cli:app