Skip to content

Commit 1a29f49

Browse files
committed
feat(clash): add clash_speed.py for proxy management
1 parent cce780a commit 1a29f49

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

scripts/clash/clash_speed.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import os
2+
import subprocess
3+
import time
4+
import argparse
5+
import logging
6+
import requests
7+
import threading
8+
import json
9+
import urllib.parse
10+
11+
# Assuming speed.py is in the same directory or accessible in PYTHONPATH
12+
from speed import get_top_proxies
13+
14+
# --- Configuration ---
15+
CLASH_CONTROLLER_HOST = "127.0.0.1"
16+
CLASH_CONTROLLER_PORT = 9090
17+
CLASH_API_BASE_URL = f"http://{CLASH_CONTROLLER_HOST}:{CLASH_CONTROLLER_PORT}"
18+
# The group proxy name to which the best individual proxy will be assigned.
19+
# Make sure this group exists in your Clash configuration.
20+
TARGET_PROXY_GROUP = "🚧Proxy"
21+
22+
def setup_logging():
23+
"""Configures basic logging for the script. Clears previous log."""
24+
if os.path.exists('clash.log'):
25+
with open('clash.log', 'w'): # clears the log file
26+
pass
27+
logging.basicConfig(
28+
filename='clash.log',
29+
level=logging.INFO,
30+
format='%(asctime)s - %(message)s',
31+
datefmt='%Y-%m-%d %H:%M:%S'
32+
)
33+
34+
def start_system_proxy(global_proxy_address):
35+
"""Sets system-wide proxy environment variables."""
36+
os.environ["GLOBAL_PROXY"] = global_proxy_address # Set for consistency if needed elsewhere
37+
os.environ["HTTP_PROXY"] = f"http://{global_proxy_address}"
38+
os.environ["HTTPS_PROXY"] = f"http://{global_proxy_address}"
39+
os.environ["http_proxy"] = f"http://{global_proxy_address}"
40+
os.environ["https_proxy"] = f"http://{global_proxy_address}"
41+
# These typically don't need to be explicitly set to "false" with modern tools,
42+
# but keeping for compatibility with your original script's intent.
43+
os.environ["HTTP_PROXY_REQUEST_FULLURI"] = "false"
44+
os.environ["HTTPS_PROXY_REQUEST_FULLURI"] = "false"
45+
os.environ["ALL_PROXY"] = os.environ["http_proxy"]
46+
logging.info(f"System-wide proxy set to: {global_proxy_address}")
47+
48+
def stop_system_proxy():
49+
"""Clears system-wide proxy environment variables."""
50+
os.environ["http_proxy"] = ""
51+
os.environ["HTTP_PROXY"] = ""
52+
os.environ["https_proxy"] = ""
53+
os.environ["HTTPS_PROXY"] = ""
54+
os.environ["HTTP_PROXY_REQUEST_FULLURI"] = "true" # Revert to default
55+
os.environ["HTTPS_PROXY_REQUEST_FULLURI"] = "true"
56+
os.environ["ALL_PROXY"] = ""
57+
logging.info("System-wide proxy stopped (environment variables cleared).")
58+
59+
def switch_clash_proxy_group(group_name, proxy_name):
60+
"""
61+
Switches the active proxy in a specified Clash proxy group to a new proxy.
62+
"""
63+
encoded_group_name = urllib.parse.quote(group_name)
64+
url = f"{CLASH_API_BASE_URL}/proxies/{encoded_group_name}"
65+
headers = {"Content-Type": "application/json"}
66+
payload = {"name": proxy_name}
67+
68+
try:
69+
response = requests.put(url, headers=headers, data=json.dumps(payload), timeout=5)
70+
response.raise_for_status()
71+
logging.info(f"Successfully switched '{group_name}' to '{proxy_name}'.")
72+
return True
73+
except requests.exceptions.ConnectionError:
74+
logging.error(f"Error: Could not connect to Clash API at {CLASH_API_BASE_URL} to switch proxy.")
75+
logging.error("Ensure Clash is running and its external-controller is configured.")
76+
return False
77+
except requests.exceptions.Timeout:
78+
logging.error(f"Error: Connection to Clash API timed out while switching proxy for '{group_name}'.")
79+
return False
80+
except requests.exceptions.RequestException as e:
81+
logging.error(f"An unexpected error occurred while switching proxy for '{group_name}': {e}")
82+
return False
83+
84+
def main():
85+
"""Main function to start Clash, then periodically select and switch to best proxy."""
86+
setup_logging()
87+
88+
parser = argparse.ArgumentParser(description="Clash management script for periodic proxy switching.")
89+
parser.add_argument("--minutes", type=int, default=10, help="Minutes between updates (default: 10)")
90+
parser.add_argument("--iterations", type=int, default=1000, help="Number of iterations (default: 1000)")
91+
parser.add_argument(
92+
"--clash-executable",
93+
type=str,
94+
default=os.getenv("CLASH_EXECUTABLE"),
95+
help="Path to the Clash executable. Defaults to CLASH_EXECUTABLE environment variable if set."
96+
)
97+
parser.add_argument(
98+
"--hk",
99+
action="store_true",
100+
help="Include HK proxies in selection (not just SG/TW)"
101+
)
102+
args = parser.parse_args()
103+
104+
ITERATIONS = args.iterations
105+
SLEEP_SECONDS = args.minutes * 60
106+
clash_executable_path = args.clash_executable
107+
108+
if not clash_executable_path:
109+
logging.critical("Error: No Clash executable path provided. Please set CLASH_EXECUTABLE environment variable or use --clash-executable argument.")
110+
return # Exit if no executable path is available
111+
112+
# Step 1: Stop any existing system proxy settings
113+
stop_system_proxy()
114+
115+
# Step 2: Start Clash in the background
116+
clash_process = None
117+
try:
118+
# Start Clash and redirect its output to a logging function instead of a file
119+
def log_clash_output(pipe, level=logging.INFO):
120+
for line in iter(pipe.readline, b''):
121+
logging.log(level, f"[Clash] {line.decode(errors='replace').rstrip()}")
122+
pipe.close()
123+
124+
clash_process = subprocess.Popen(
125+
[clash_executable_path],
126+
stdout=subprocess.PIPE,
127+
stderr=subprocess.PIPE
128+
)
129+
logging.info(f"Clash started with PID {clash_process.pid}")
130+
131+
# Start threads to capture and log stdout and stderr
132+
threading.Thread(target=log_clash_output, args=(clash_process.stdout, logging.INFO), daemon=True).start()
133+
threading.Thread(target=log_clash_output, args=(clash_process.stderr, logging.ERROR), daemon=True).start()
134+
135+
# Give Clash a moment to fully initialize and open its API port
136+
time.sleep(5)
137+
except FileNotFoundError:
138+
logging.critical(f"Clash executable not found at: {clash_executable_path}")
139+
logging.critical("Please ensure the path is correct and Clash is installed.")
140+
return # Critical error, exit script
141+
except Exception as e:
142+
logging.error(f"Failed to start Clash: {e}")
143+
return
144+
145+
# Set the system-wide proxy to point to Clash's local HTTP proxy.
146+
# Clash typically runs its HTTP proxy on port 7890 (or similar, check your config).
147+
clash_local_proxy_address = f"{CLASH_CONTROLLER_HOST}:7890" # Adjust if your Clash HTTP port is different
148+
start_system_proxy(clash_local_proxy_address)
149+
150+
for i in range(1, ITERATIONS + 1):
151+
logging.info(f"--- Starting Iteration {i} of {ITERATIONS} ---")
152+
153+
# Step 3: Test proxy speeds and select the best one
154+
best_proxy_name = None
155+
try:
156+
logging.info("Testing proxy speeds to find the best one...")
157+
top_proxies = get_top_proxies(num_results=20) # Get top 20 proxies
158+
if top_proxies:
159+
# Check for SG or TW in proxy names (or HK if --hk is set)
160+
for proxy in top_proxies:
161+
proxy_name = proxy['name']
162+
if args.hk:
163+
if any(x in proxy_name for x in ['HK', 'SG', 'TW']):
164+
best_proxy_name = proxy_name
165+
logging.info(f"Selected proxy '{best_proxy_name}' (contains HK/SG/TW) with latency {proxy['latency']}ms")
166+
break
167+
else:
168+
if any(x in proxy_name for x in ['SG', 'TW']):
169+
best_proxy_name = proxy_name
170+
logging.info(f"Selected proxy '{best_proxy_name}' (contains SG/TW) with latency {proxy['latency']}ms")
171+
break
172+
# If no matching proxy is found, use the first one
173+
if not best_proxy_name:
174+
best_proxy_name = top_proxies[0]['name']
175+
logging.info(f"No preferred proxy found. Selected first proxy '{best_proxy_name}' with latency {top_proxies[0]['latency']}ms")
176+
else:
177+
logging.warning("No successful proxy tests. Cannot select a best proxy for this iteration.")
178+
except Exception as e:
179+
logging.error(f"Error during proxy speed testing: {e}")
180+
181+
# Step 4: Switch Clash's proxy group to the best proxy (if found)
182+
if best_proxy_name:
183+
if not switch_clash_proxy_group(TARGET_PROXY_GROUP, best_proxy_name):
184+
logging.error(f"Failed to switch Clash group '{TARGET_PROXY_GROUP}' to '{best_proxy_name}'.")
185+
else:
186+
logging.warning("No best proxy found, skipping proxy group switch for this iteration.")
187+
188+
# Step 5: Wait for the specified duration
189+
logging.info(f"Waiting for {args.minutes} minutes before next iteration...")
190+
time.sleep(SLEEP_SECONDS)
191+
192+
logging.info(f"--- Iteration {i} completed ---")
193+
194+
# Step 6: Stop Clash process
195+
if clash_process:
196+
logging.info("Terminating Clash process...")
197+
logging.info("Terminating Clash process...")
198+
clash_process.terminate()
199+
try:
200+
clash_process.wait(timeout=10) # Give Clash a bit more time to shut down gracefully
201+
logging.info("Clash stopped successfully.")
202+
except subprocess.TimeoutExpired:
203+
logging.warning("Clash did not terminate gracefully, killing process.")
204+
clash_process.kill()
205+
clash_process.wait() # Ensure process is fully killed
206+
except Exception as e:
207+
logging.error(f"Error while waiting for Clash to stop: {e}")
208+
209+
stop_system_proxy()
210+
211+
logging.info(f"Completed {ITERATIONS} iterations. Script finished.")
212+
213+
if __name__ == "__main__":
214+
main()

0 commit comments

Comments
 (0)