subscription-2vless 1.0.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: subscription-2vless
3
+ Version: 1.0.0
4
+ Requires-Python: >=3.12
5
+ Description-Content-Type: text/markdown
6
+ Requires-Dist: httpx<1,>=0.28.1
7
+
8
+ # subscription-2vless
@@ -0,0 +1 @@
1
+ # subscription-2vless
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "subscription-2vless"
3
+ version = "1.0.0"
4
+ readme = "README.md"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "httpx>=0.28.1,<1"
8
+ ]
9
+
10
+ [build-system]
11
+ requires = ["setuptools>=61"]
12
+ build-backend = "setuptools.build_meta"
13
+
14
+ [project.scripts]
15
+ subscription-2vless = "subscription_2vless.cli:main"
16
+
17
+ [tool.setuptools]
18
+ package-dir = {"" = "src"}
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+ include = ["subscription_2vless*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ from subscription_2vless.schemas import (
2
+ Http,
3
+ Https,
4
+ Hysteria,
5
+ Hysteria2,
6
+ Shadowsocks,
7
+ ShadowsocksR,
8
+ Socks,
9
+ Trojan,
10
+ Tuic,
11
+ Vless,
12
+ Vmess,
13
+ VPNType,
14
+ WireGuard,
15
+ parse_type_link,
16
+ )
17
+ from subscription_2vless.subscription import (
18
+ SubscriptionHeaders,
19
+ get_vpn_links_from_subscription,
20
+ )
21
+
22
+ __all__ = [
23
+ "Http",
24
+ "Https",
25
+ "Hysteria",
26
+ "Hysteria2",
27
+ "Shadowsocks",
28
+ "ShadowsocksR",
29
+ "Socks",
30
+ "SubscriptionHeaders",
31
+ "Trojan",
32
+ "Tuic",
33
+ "VPNType",
34
+ "Vless",
35
+ "Vmess",
36
+ "WireGuard",
37
+ "get_vpn_links_from_subscription",
38
+ "parse_type_link",
39
+ ]
@@ -0,0 +1,140 @@
1
+ import argparse
2
+ import sys
3
+ from collections.abc import Sequence
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+
8
+ from subscription_2vless.subscription import get_vpn_links_from_subscription
9
+
10
+ _DEFAULT_OUTPUT_FILE = Path("links.txt")
11
+
12
+
13
+ def _build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="subscription-2vless",
16
+ description=(
17
+ "Fetch a VPN subscription, display all supported connection URLs, "
18
+ "and optionally save them to a text file"
19
+ ),
20
+ )
21
+ parser.add_argument(
22
+ "url",
23
+ nargs="?",
24
+ help="Subscription URL. If omitted, the CLI asks for it interactively",
25
+ )
26
+ parser.add_argument(
27
+ "-o",
28
+ "--output",
29
+ type=Path,
30
+ metavar="FILE",
31
+ help="Save all discovered URLs to FILE without asking for a filename",
32
+ )
33
+ parser.add_argument(
34
+ "--no-save",
35
+ action="store_true",
36
+ help="Do not offer to save the discovered URLs",
37
+ )
38
+ parser.add_argument(
39
+ "--force",
40
+ action="store_true",
41
+ help="Overwrite the output file if it already exists (requires --output)",
42
+ )
43
+ return parser
44
+
45
+
46
+ def _ask_to_save() -> bool:
47
+ try:
48
+ answer = input("\nSave all URLs to a file? [y/N]: ").strip().casefold()
49
+ except EOFError:
50
+ return False
51
+ return answer in {"y", "yes"}
52
+
53
+
54
+ def _ask_for_output_path() -> Path | None:
55
+ try:
56
+ value = input(f"Output file [{_DEFAULT_OUTPUT_FILE}]: ").strip()
57
+ except EOFError:
58
+ return None
59
+ return Path(value) if value else _DEFAULT_OUTPUT_FILE
60
+
61
+
62
+ def _write_urls(urls: Sequence[str], output: Path, *, force: bool) -> int:
63
+ if output.exists() and not force:
64
+ try:
65
+ answer = (
66
+ input(f"File '{output}' already exists. Overwrite it? [y/N]: ")
67
+ .strip()
68
+ .casefold()
69
+ )
70
+ except EOFError:
71
+ answer = ""
72
+ if answer not in {"y", "yes"}:
73
+ print("File was not changed.")
74
+ return 0
75
+
76
+ try:
77
+ output.write_text("\n".join(urls) + "\n", encoding="utf-8")
78
+ except OSError as exc:
79
+ print(f"Could not write '{output}': {exc}", file=sys.stderr)
80
+ return 1
81
+
82
+ print(f"Saved {len(urls)} URL(s) to '{output}'.")
83
+ return 0
84
+
85
+
86
+ def main(argv: Sequence[str] | None = None) -> int:
87
+ parser = _build_parser()
88
+ args = parser.parse_args(argv)
89
+
90
+ if args.force and args.output is None:
91
+ parser.error("--force can only be used together with --output")
92
+ if args.no_save and args.output is not None:
93
+ parser.error("--no-save cannot be used together with --output")
94
+
95
+ url = args.url
96
+ if url is None:
97
+ try:
98
+ url = input("Subscription URL: ").strip()
99
+ except EOFError:
100
+ url = ""
101
+
102
+ if not url:
103
+ print("A subscription URL is required.", file=sys.stderr)
104
+ return 2
105
+
106
+ print("Fetching subscription...")
107
+ try:
108
+ links = get_vpn_links_from_subscription(url)
109
+ except (httpx.HTTPError, httpx.InvalidURL, ValueError, UnicodeError) as exc:
110
+ print(f"Failed to fetch or parse the subscription: {exc}", file=sys.stderr)
111
+ return 1
112
+
113
+ if not links:
114
+ print("No supported VPN URLs were found.")
115
+ return 0
116
+
117
+ urls = [str(link) for link in links]
118
+ print(f"\nFound {len(urls)} VPN URL(s):\n")
119
+ for index, item in enumerate(urls, start=1):
120
+ print(f"{index:>3}. {item}")
121
+
122
+ if args.no_save:
123
+ return 0
124
+
125
+ if args.output is not None:
126
+ return _write_urls(urls, args.output, force=args.force)
127
+
128
+ if not _ask_to_save():
129
+ print("No file was created.")
130
+ return 0
131
+
132
+ output = _ask_for_output_path()
133
+ if output is None:
134
+ print("No file was created.")
135
+ return 0
136
+ return _write_urls(urls, output, force=False)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())
@@ -0,0 +1,128 @@
1
+ import base64
2
+ import json
3
+ from typing import Any, ClassVar, Self
4
+
5
+
6
+ class VPNType(str):
7
+ schemes: ClassVar[tuple[str, ...]] = ()
8
+
9
+ def __new__(cls, value: str) -> Self:
10
+ value = value.strip()
11
+ scheme, separator, _ = value.partition("://")
12
+ if not separator:
13
+ raise ValueError(f"Missing URL scheme: {value[:30]!r}")
14
+ if scheme.lower() not in cls.schemes:
15
+ raise ValueError(f"{cls.__name__}: unsupported scheme {scheme!r}")
16
+ return super().__new__(cls, value)
17
+
18
+ @property
19
+ def scheme(self) -> str:
20
+ return str(self).partition("://")[0].lower()
21
+
22
+ @property
23
+ def fragment(self) -> str:
24
+ return str(self).partition("#")[2]
25
+
26
+ def __repr__(self) -> str:
27
+ return f"{type(self).__name__}({str.__repr__(self)})"
28
+
29
+
30
+ class Vless(VPNType):
31
+ schemes = ("vless",)
32
+
33
+
34
+ class Vmess(VPNType):
35
+ schemes = ("vmess",)
36
+
37
+ @property
38
+ def payload(self) -> dict[str, Any]:
39
+ encoded = str(self)[8:].partition("#")[0]
40
+ encoded += "=" * (-len(encoded) % 4)
41
+ payload = json.loads(base64.urlsafe_b64decode(encoded))
42
+ if not isinstance(payload, dict):
43
+ raise TypeError("VMess payload must be a JSON object")
44
+ return payload
45
+
46
+
47
+ class Shadowsocks(VPNType):
48
+ schemes = ("ss",)
49
+
50
+ @property
51
+ def method_and_password(self) -> tuple[str, str]:
52
+ body = str(self)[5:].partition("#")[0]
53
+ encoded, separator, _ = body.partition("@")
54
+ if not separator:
55
+ encoded = body
56
+ encoded += "=" * (-len(encoded) % 4)
57
+ decoded = base64.urlsafe_b64decode(encoded).decode().partition("@")[0]
58
+ method, separator, password = decoded.partition(":")
59
+ if not separator:
60
+ raise ValueError("SS payload is missing the method:password separator")
61
+ return method, password
62
+
63
+
64
+ class ShadowsocksR(VPNType):
65
+ schemes = ("ssr",)
66
+
67
+
68
+ class Trojan(VPNType):
69
+ schemes = ("trojan",)
70
+
71
+
72
+ class Hysteria(VPNType):
73
+ schemes = ("hysteria",)
74
+
75
+
76
+ class Hysteria2(VPNType):
77
+ schemes = ("hysteria2", "hy2")
78
+
79
+
80
+ class Tuic(VPNType):
81
+ schemes = ("tuic",)
82
+
83
+
84
+ class WireGuard(VPNType):
85
+ schemes = ("wireguard", "wg")
86
+
87
+
88
+ class Socks(VPNType):
89
+ schemes = ("socks", "socks4", "socks5")
90
+
91
+
92
+ class Http(VPNType):
93
+ schemes = ("http",)
94
+
95
+
96
+ class Https(VPNType):
97
+ schemes = ("https",)
98
+
99
+
100
+ _REGISTRY = {
101
+ scheme: cls
102
+ for cls in (
103
+ Vless,
104
+ Vmess,
105
+ Shadowsocks,
106
+ ShadowsocksR,
107
+ Trojan,
108
+ Hysteria,
109
+ Hysteria2,
110
+ Tuic,
111
+ WireGuard,
112
+ Socks,
113
+ Http,
114
+ Https,
115
+ )
116
+ for scheme in cls.schemes
117
+ }
118
+
119
+
120
+ def parse_type_link(value: str) -> VPNType:
121
+ value = value.strip()
122
+ scheme, separator, _ = value.partition("://")
123
+ if not separator:
124
+ raise ValueError(f"Not a valid connection URL: {value[:30]!r}")
125
+ try:
126
+ return _REGISTRY[scheme.lower()](value)
127
+ except KeyError:
128
+ raise ValueError(f"Unknown protocol: {scheme!r}") from None
@@ -0,0 +1,48 @@
1
+ import base64
2
+ import binascii
3
+ import uuid
4
+
5
+ import httpx
6
+ from httpx import Headers
7
+
8
+ from subscription_2vless.schemas import VPNType, parse_type_link
9
+
10
+
11
+ class SubscriptionHeaders(Headers):
12
+ def __init__(
13
+ self,
14
+ *,
15
+ HWID: str | None = None,
16
+ user_agent: str = "Incy/1.2.0 (Android; 14)",
17
+ ) -> None:
18
+ super().__init__(
19
+ {
20
+ "X-HWID": HWID or str(uuid.uuid4()),
21
+ "User-Agent": user_agent,
22
+ }
23
+ )
24
+
25
+
26
+ def get_vpn_links_from_subscription(
27
+ url: str,
28
+ headers: SubscriptionHeaders | None = None,
29
+ ) -> list[VPNType]:
30
+ request_headers = headers if headers else SubscriptionHeaders()
31
+ with httpx.Client(timeout=10.0, follow_redirects=True) as session:
32
+ response = session.get(str(url), headers=request_headers)
33
+
34
+ response.raise_for_status()
35
+ content = response.content
36
+
37
+ try:
38
+ text = base64.b64decode(content, validate=True).decode()
39
+ except (binascii.Error, UnicodeDecodeError):
40
+ text = content.decode()
41
+
42
+ links = []
43
+ for item in text.split():
44
+ try:
45
+ links.append(parse_type_link(item))
46
+ except ValueError:
47
+ pass
48
+ return links
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: subscription-2vless
3
+ Version: 1.0.0
4
+ Requires-Python: >=3.12
5
+ Description-Content-Type: text/markdown
6
+ Requires-Dist: httpx<1,>=0.28.1
7
+
8
+ # subscription-2vless
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/subscription_2vless/__init__.py
4
+ src/subscription_2vless/cli.py
5
+ src/subscription_2vless/schemas.py
6
+ src/subscription_2vless/subscription.py
7
+ src/subscription_2vless.egg-info/PKG-INFO
8
+ src/subscription_2vless.egg-info/SOURCES.txt
9
+ src/subscription_2vless.egg-info/dependency_links.txt
10
+ src/subscription_2vless.egg-info/entry_points.txt
11
+ src/subscription_2vless.egg-info/requires.txt
12
+ src/subscription_2vless.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ subscription-2vless = subscription_2vless.cli:main
@@ -0,0 +1 @@
1
+ subscription_2vless