소스 검색

仅完成了订阅。在进程管理方面,发现无法很高查询和管理数据,可能要依靠数据库

mrh 10 달 전
커밋
969336b19e

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+.aider*
+.env
+__pycache__
+output

+ 5 - 0
.vscode/settings.json

@@ -0,0 +1,5 @@
+{
+    "python.analysis.extraPaths": [
+        "./backend",
+    ]
+}

+ 45 - 0
CONVENTIONS.md

@@ -0,0 +1,45 @@
+# 编程规范
+为了保持程序的通用性、扩展性和兼容性,代码设计应遵循以下原则:
+
+1. 模块化设计:将功能分解为独立的模块或类,每个模块或类应专注于单一职责。避免将多个不相关的业务逻辑集中在一个类或函数中。
+
+2. 高内聚低耦合:确保每个模块或类内部的元素紧密相关(高内聚),同时减少模块或类之间的依赖关系(低耦合)。这样可以使代码更易于维护和扩展。
+
+3. 单一职责原则:每个函数或方法应只执行一个最小化的任务。如果一个函数或方法包含多个步骤,应考虑将其分解为多个更小的函数或方法。
+
+4. 按需创建:根据业务需求决定是否创建新的文件、类或函数。如果现有类或函数无法满足新的需求,应考虑创建新的类或函数,而不是在现有代码中添加额外的逻辑。
+
+5. 继承与组合:合理使用继承和组合来增强代码的复用性和扩展性。优先使用组合而非继承,以避免过度复杂的继承层次。
+
+6. 注释与文档:为每个模块、类和函数添加适当的注释,说明其功能、输入输出以及使用场景。这有助于其他开发者理解和使用你的代码。
+
+7. 接口与抽象:定义清晰的接口和抽象基类,以便在不同实现之间保持兼容性。通过接口或抽象类来定义通用的行为,具体的实现可以在子类中完成。
+
+8. 可扩展性:在设计时考虑未来的扩展需求,避免硬编码和过度依赖特定实现。使用配置文件、依赖注入等方式来提高代码的灵活性。
+
+9. 兼容性:在修改或扩展代码时,确保新代码与旧代码兼容,避免破坏现有功能。可以通过版本控制、接口隔离等方式来管理兼容性问题。
+
+- 当前环境是 python 3.12 ,务必要保持最新的接口来开发,例如 Fastapi 不再使用 app.event ,而是使用 lifespan 。pydantic.BaseModel 不再支持 dict() ,而是用 model_dump()
+
+重要:由于你是在 aider 开发环境中,如果你要编写任何文件的代码,都不能省略已有代码,必须完整写完
+
+# 项目说明:
+- 这是一个基于 Fastapi + Vue3 的代理池管理。通过订阅链接,获取服务商提供的代理池数据,让 mihomo 工具启动本地代理池。
+
+## 后端
+python 搜索路径 `./backend`
+* Api
+
+启动web服务后, 默认配置下会开启 http://127.0.0.1:5010 的api接口服务:
+
+| api | method | Description | params|
+| ----| ---- | ---- | ----|
+| / | GET | api介绍 | None |
+| /get | GET | 随机获取一个代理| 可选参数: `?type=https` 过滤支持https的代理|
+| /pop | GET | 获取并删除一个代理| 可选参数: `?type=https` 过滤支持https的代理|
+| /all | GET | 获取所有代理 |可选参数: `?type=https` 过滤支持https的代理|
+| /count | GET | 查看代理数量 |None|
+| /delete | GET | 删除代理  |`?proxy=host:ip`|
+| /add | GET | 添加代理  |`?proxy=host:ip`|
+| /subscribe | PUT | 添加订阅链接 |None|
+

+ 0 - 0
backend/.clinerules


+ 13 - 0
backend/README.md

@@ -0,0 +1,13 @@
+backend\main.py 中的各个链接还不完善。例如
+
+GET /subscribe ,返回订阅列表。
+
+订阅的函数参考 backend\mihomo\config_service.py ,例如传参一个 url 连接,他会自己下载 yaml 配置文件。
+
+API 订阅接口可以支持很多个订阅链接,意味着有很多个配置文件。
+
+不同的配置文件对应着 mihomo 启动用到的配置文件,可以启动不同接口。
+
+不但可以发送 url 订阅文件 yaml ,还可以更新,还可以查询。你是否见过 clash verge 等软件,它就可以导入很多订阅链接
+
+我不需要 alias 别名了,你自己决定要什么方式定义各种配置文件,反正 yaml 文件里面也有对应的 name

+ 19 - 0
backend/config/app.yaml

@@ -0,0 +1,19 @@
+subscriptions:
+- url: http://sub.sub.sub.subsub123456789.com/answer/land?token=a7cbdde987a58068b82e52d57ee5eecd
+  file_path: g:\code\upwork\zhang_crawl_bio\local_proxy_pool\backend\output\subscriptions\d92ef962.yaml
+  updated_at: 2025-01-27 17:18:58.485513
+  error: 0
+  detail:
+    msg: 更新订阅成功
+- url: https://www.yfjc.xyz/api/v1/client/subscribe?token=b74f2207492053926f7511a8e474048f
+  file_path: g:\code\upwork\zhang_crawl_bio\local_proxy_pool\backend\output\subscriptions\6137e542.yaml
+  updated_at: 2025-01-27 17:18:57.155802
+  error: 0
+  detail:
+    msg: 更新订阅成功
+- url: http://subscr.xpoti.com/v3/subscr?id=90bd6ff1da374a89b4ba763f354f20bf
+  file_path: g:\code\upwork\zhang_crawl_bio\local_proxy_pool\backend\output\subscriptions\dde42ec3.yaml
+  updated_at: 2025-01-27 17:18:58.296957
+  error: 0
+  detail:
+    msg: 更新订阅成功

+ 59 - 0
backend/config/app_yaml.py

@@ -0,0 +1,59 @@
+
+from datetime import datetime
+from pathlib import Path
+from typing import List
+from pydantic import BaseModel
+import yaml
+from config.settings import settings
+
+def save_yaml_dump(config: dict, save_as: Path) -> Path:
+    """保存配置文件"""
+    save_as.parent.mkdir(parents=True, exist_ok=True)
+    
+    with open(save_as, 'w', encoding='utf-8') as f:
+        yaml.dump(
+            config,
+            f,
+            Dumper=yaml.SafeDumper,
+            allow_unicode=True,
+            indent=2,
+            sort_keys=False
+        )
+    return save_as
+
+class BaseYaml(BaseModel):
+    """Yaml基类"""
+    def save(self, save_as: Path=settings.APP_CONFIG) -> Path:
+        """保存配置文件"""
+        return save_yaml_dump(self.model_dump(), save_as)
+
+    @classmethod
+    def load(cls, file_path: Path=settings.APP_CONFIG) -> 'BaseYaml':
+        """加载配置文件"""
+
+class Subscription(BaseModel):
+    url: str
+    file_path: str
+    updated_at: datetime
+    error: int
+    detail: dict
+
+
+
+class AppYaml(BaseModel):
+    subscriptions: List[Subscription] = []
+
+    def save(self, save_as: Path=settings.APP_CONFIG) -> Path:
+        """保存配置文件"""
+        save_yaml_dump(self.model_dump(), save_as)
+        return self.load(save_as)
+
+    @classmethod
+    def load(cls, file_path: Path=settings.APP_CONFIG) -> 'AppYaml':
+        """加载配置文件"""
+        with open(file_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f) or {}
+        # print(type(config), config)
+        return cls(**config)
+
+app_yaml = AppYaml.load()

+ 55 - 0
backend/config/logu.py

@@ -0,0 +1,55 @@
+import logging
+import os
+import sys
+import loguru
+import os
+import sys
+
+from config.settings import settings
+LOG_DIR = settings.LOG_CONF.LOG_DIR
+
+
+# python_xx/site-packages/loguru/_handler.py  _serialize_record
+FORMAT = '<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file}:{line}</cyan> :<cyan>{function}</cyan> - {message}'
+loguru.logger.remove()
+# logger.add(sys.stderr, format='<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>')
+# logger.add(sys.stderr, format=FORMAT)
+# logger.add(LOG_FILE, format=FORMAT)
+if not os.path.exists(LOG_DIR):
+    os.mkdir(LOG_DIR)
+
+loggers = {} 
+FORMAT = '<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file}:{line}</cyan> :<cyan>{function}</cyan> - {message}'
+
+def get_logger(name, console=True, console_level="INFO", file=True, file_level="DEBUG"):  
+    '''
+    用法
+        # 创建普通日志,并且启用控制台输出,默认保存到 {LOG_DIR}/default.log 文件  
+        logger = get_logger("default", console=True) 
+        
+        # 创建特定名称的日志和日志文件,保存到 {LOG_DIR}/斌的世界/gift.log 文件
+        user_log = get_logger("斌的世界/gift")
+
+        # 输出打印测试
+        user_log.info("将控制台日志器、文件日志器,添加进日志器对象中")
+        logger.info("这是一条info消息")
+    '''
+    global loggers  
+    if name in loggers:  
+        return loggers[name]  # 如果已经存在,则直接返回      # 创建用户特定的日志文件  
+    log_file = f"{name}.log"  
+    # 添加日志处理器,过滤出只包含该用户名的日志记录  
+    if file:
+        loguru.logger.add(LOG_DIR/log_file,level=file_level, format=FORMAT, filter=lambda record: record["extra"].get("name") == name)
+    if console:
+        loguru.logger.add(sys.stderr, format=FORMAT,level=console_level, filter=lambda record: record["extra"].get("name") == name)
+    user_logger = loguru.logger.bind(name=name)  
+    loggers[name] = user_logger  
+    return user_logger  
+
+logger = get_logger('main')
+
+if __name__ == '__main__':
+    user_log = get_logger("斌的世界/gift")
+    user_log.info("将控制台日志器、文件日志器,添加进日志器对象中")
+    logger.info("这是一条info消息")

+ 41 - 0
backend/config/settings.py

@@ -0,0 +1,41 @@
+from pathlib import Path
+from typing import Any
+
+from pydantic import BaseModel
+from pydantic_settings import BaseSettings
+
+
+class LogConfig(BaseModel):
+    datetime_format: str = "%Y-%m-%d %H:%M:%S"
+    LOG_LEVEL: str = "DEBUG"  # 修改日志级别为 DEBUG
+    LOG_DIR: Path = ''
+
+class Settings(BaseSettings):
+    # 基础配置
+    BASE_DIR: Path = Path(__file__).parent.parent.absolute()
+    OUTPUT_DIR: Path = BASE_DIR / "output"
+    APP_NAME: str = "proxy-pool"
+    DEBUG: bool = True
+    HOST: str = "0.0.0.0"
+    PORT: int = 5010
+    WORKERS: int = 1
+    LOG_CONF: LogConfig = LogConfig(LOG_DIR = Path(OUTPUT_DIR / "logs"))
+
+    SUBSCRIPTION_USER_AGENT: str = "clash-verge/v1.7.5"
+    
+    # mihomo 配置
+    MIHOMO_BIN_PATH: Path = BASE_DIR / r"output\download\mihomo\mihomo-windows-amd64-go120.exe"
+    MIHOMO_TEMP_PATH: Path = OUTPUT_DIR / "temp"
+    MIHOMO_DOWNLOAD_URL: str = "https://github.com/MetaCubeX/mihomo/releases/download/v1.18.6/mihomo-windows-amd64-go120-v1.18.6.zip"
+
+    # 路径配置
+    PATH_CONFIG_DIR: Path = BASE_DIR / "output/configs"
+    PATH_SUBSCRIPTION_DIR: Path = BASE_DIR / "output/subscriptions"
+    
+    APP_CONFIG:Path = BASE_DIR / "config/app.yaml"
+
+    class Config:
+        case_sensitive = True
+
+
+settings = Settings()

+ 6 - 0
backend/hello.py

@@ -0,0 +1,6 @@
+def main():
+    print("Hello from backend!")
+
+
+if __name__ == "__main__":
+    main()

+ 53 - 0
backend/main.py

@@ -0,0 +1,53 @@
+from fastapi import FastAPI
+from contextlib import asynccontextmanager
+import uvicorn
+from pathlib import Path
+from datetime import datetime
+from typing import List, Dict, Optional
+from pydantic import BaseModel
+import yaml
+import asyncio
+import httpx
+from config.settings import settings
+from routers import configs, subscriptions
+from utils.mihomo_service import download_mihomo
+from routers.mihomo import POOL
+from aiomultiprocess import Pool
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """应用生命周期管理"""
+    # 初始化目录结构
+    settings.PATH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+    settings.PATH_SUBSCRIPTION_DIR.mkdir(parents=True, exist_ok=True)
+    # 检查并下载mihomo
+    await download_mihomo()
+    POOL = Pool()
+    yield
+    if POOL is not None:
+        await POOL.close()
+
+app = FastAPI(lifespan=lifespan)
+
+# 注册路由
+app.include_router(configs.router, prefix="/configs", tags=["配置管理"])
+app.include_router(subscriptions.router, prefix="/subscriptions", tags=["订阅管理"])
+
+# 添加健康检查路由
+@app.get("/health")
+async def health_check():
+    """服务健康检查"""
+    return {"status": "healthy"}
+
+@app.get("/")
+async def root():
+    return {
+        "message": "代理池管理API",
+        "endpoints": {
+            "/docs": "API文档",
+            "/configs": "配置管理",
+            "/subscriptions": "订阅管理"
+        }
+    }
+
+if __name__ == "__main__":
+    uvicorn.run("main:app", host=settings.HOST, port=settings.PORT, reload=settings.DEBUG)

+ 18 - 0
backend/models/config.py

@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+from typing import Optional
+
+class ConfigResponse(BaseModel):
+    port: int
+    control_port: int
+    config_path: str
+    command: str
+    status: str
+
+class ConfigDetail(BaseModel):
+    name: str
+    type: Optional[str] = None
+    server: str
+    port: int
+    cipher: Optional[str] = None
+    password: Optional[str] = None
+    protocol: Optional[str] = None

+ 14 - 0
backend/pyproject.toml

@@ -0,0 +1,14 @@
+[project]
+name = "backend"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+    "fastapi>=0.115.7",
+    "httpx>=0.28.1",
+    "loguru>=0.7.3",
+    "pydantic>=2.10.6",
+    "pydantic-settings>=2.7.1",
+    "uvicorn>=0.34.0",
+]

+ 76 - 0
backend/routers/mihomo.py

@@ -0,0 +1,76 @@
+import asyncio
+import hashlib
+from fastapi import APIRouter, HTTPException
+from datetime import datetime, timedelta
+from pydantic import BaseModel
+from typing import Dict, List, Optional
+import httpx
+import yaml
+from config.logu import logger, get_logger
+from config.settings import settings
+from config.app_yaml import app_yaml, Subscription
+from routers.subscriptions import list_subscriptions,SubscriptionResponse
+from utils.mihomo_service import port_is_using,find_free_port
+from utils.sub import update_config
+# 初始化全局变量来保存进程池
+POOL = None
+processes = []
+mihomo_running_status = {}
+mihomo_router = APIRouter()
+
+
+class MihomoBatchRequest(BaseModel):
+    provider_name: str
+    proxy_name: str
+    port: Optional[int] = None
+
+class MihomoRunningStatus(MihomoBatchRequest):
+    pid: int
+    started_at: datetime
+
+class ProcessInfo(BaseModel):
+    provider_name: str
+    
+class MihomoResponse(MihomoBatchRequest):
+    error: int = 0
+    detail: Optional[Dict] = None
+async def start_mihomo(bin_exe: str, config_yaml_path: str):
+    global POOL
+    process = await asyncio.create_subprocess_exec(
+            bin_exe,
+            "-f",
+            str(config_yaml_path),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+    processes.append(process)  
+    return process
+
+async def stop_mihomo(process: asyncio.subprocess.Process):
+    process.terminate()
+    await process.wait()
+    processes.remove(process)  
+@mihomo_router.post("/")
+async def post_start_mihomo(request: MihomoBatchRequest):
+    res_list:List[SubscriptionResponse] = await list_subscriptions()
+    provider = next((item for item in res_list if item.provider_name == request.provider_name), None)
+    if provider is None:
+        return MihomoResponse(
+            **request.model_dump(),
+            error=1,
+            detail={"msg": "provider not found"},
+        )
+    if not port_is_using(request.port):
+        return MihomoResponse(
+            **request.model_dump(),
+            error=1,
+            detail={"msg": "port is using"},
+        )
+    if mihomo_running_status.get(provider.file_name, {}).get(request.proxy_name, {}):
+        return MihomoResponse(
+            **request.model_dump(),
+            error=1,
+            detail={"msg": "mihomo is running"},
+        ) 
+    logger.info(f"start: {provider}")
+    settings.MIHOMO_BIN_PATH

+ 165 - 0
backend/routers/readme.md

@@ -0,0 +1,165 @@
+
+
+
+
+实际上,这些链接会调用以下参考函数去访问获得对应的 yaml 配置文件:
+```python
+import httpx
+import yaml
+from pathlib import Path
+from typing import Optional, Dict, Any
+from fastapi import HTTPException
+
+sub_url = 'https://www.yfjc.xyz/api/v1/client/subscribe?token=b74f2207492053926f7511a8e474048f'
+OUTPUT = Path(__file__).parent.parent.absolute() / "output"
+
+def get_sub(sub_url: str, save_path: str) -> Path:
+    """获取订阅文件"""
+    headers = {'User-Agent': 'clash-verge/v1.7.5'}
+    try:
+        resp = httpx.get(sub_url, headers=headers, follow_redirects=True, timeout=10)
+        resp.raise_for_status()
+    except httpx.HTTPError as e:
+        raise HTTPException(status_code=500, detail=f"订阅获取失败: {str(e)}")
+    
+    save_path = Path(save_path)
+    save_path.parent.mkdir(parents=True, exist_ok=True)
+    
+    with open(save_path, 'w', encoding='utf-8') as f:
+        f.write(resp.text)
+    return save_path
+
+def update_config(
+    read_path: Path,
+    config_update: dict,
+    save_as: Optional[Path] = None,
+) -> Path:
+    """更新配置文件"""
+    config: Dict[str, Any] = {}
+    if read_path.exists():
+        with open(read_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f) or {}
+    
+    config.update(config_update)
+    
+    save_as = save_as or read_path
+    save_as.parent.mkdir(parents=True, exist_ok=True)
+    
+    with open(save_as, 'w', encoding='utf-8') as f:
+        yaml.dump(
+            config,
+            f,
+            Dumper=yaml.SafeDumper,
+            allow_unicode=True,
+            indent=2,
+            sort_keys=False
+        )
+    return save_as
+
+```
+
+因此, POST /subscriptions 应该是实现 get_sub 、 save_path 的操作。 
+
+```
+POST /subscriptions
+{
+  "urls": [
+    "http://sub.sub.sub.subsub123456789.com/answer/land?token=a7cbdde987a58068b82e52d57ee5eecd",
+    "https://www.yfjc.xyz/api/v1/client/subscribe?token=b74f2207492053926f7511a8e474048f",
+    "http://subscr.xpoti.com/v3/subscr?id=90bd6ff1da374a89b4ba763f354f20bf"
+  ]
+}
+
+Response:
+
+```
+
+具体 get_sub 得到的yaml 文件类似如下,当我调用API接口时, GET /subscriptions 应该可以返回所有 yaml 文件的 json 格式:
+```yaml
+mixed-port: 9360
+allow-lan: false
+bind-address: '*'
+mode: rule
+log-level: info
+external-controller: 127.0.0.1:9361
+dns:
+  enable: true
+  ipv6: false
+  default-nameserver: [223.5.5.5, 119.29.29.29]
+  enhanced-mode: fake-ip
+  fake-ip-range: 198.18.0.1/16
+proxies:
+    - { name: '剩余流量:855.2 GB', server: 163.123.192.140, port: 54000, ports: 54000-54060, mport: 54000-54060, udp: true, skip-cert-verify: true, sni: www.bing.com, type: hysteria2, password: 9ecf3874-29dc-482f-a935-91d1d7c6a82a }
+    - { name: 套餐到期:长期有效, server: 163.123.192.140, port: 54000, ports: 54000-54060, mport: 54000-54060, udp: true, skip-cert-verify: true, sni: www.bing.com, type: hysteria2, password: 9ecf3874-29dc-482f-a935-91d1d7c6a82a }
+    - { name: 🇺🇸美国凤凰城专线1, server: 163.123.192.140, port: 54000, ports: 54000-54060, mport: 54000-54060, udp: true, skip-cert-verify: true, sni: www.bing.com, type: hysteria2, password: 9ecf3874-29dc-482f-a935-91d1d7c6a82a }
+proxy-groups:
+    - { name: 一分机场, type: select, proxies: [自动选择, 故障转移, '剩余流量:855.2 GB', 套餐到期:长期有效, 🇺🇸美国凤凰城专线1, 🇺🇸美国凤凰城专线2, 🇦🇺澳大利亚悉尼, 🇦🇺澳大利亚悉尼2, 🇳🇱荷兰专线, 🇯🇵日本专线, 🇯🇵日本专线3, 🇯🇵日本专线4, 🇸🇬新加坡专线, 🇸🇬新加坡专线2, 🇺🇸美国洛杉矶专线, 🇺🇸美国洛杉矶专线2, 🇺🇸美国洛杉矶专线3, 🇺🇸美国洛杉矶专线4, 🇰🇷韩国三网专线, 🇰🇷韩国三网专线2, 🇰🇷韩国三网专线3, 🇰🇷韩国专线2, 🇧🇷巴西专线, 🇸🇬亚马逊新加坡, 🇮🇳印度孟买专线2, 🇺🇸美国高速01, 🇺🇸美国高速02, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍率', 🇭🇰香港1号, 🇭🇰香港2号, 🇭🇰香港3号, 🇭🇰香港4号, 🇺🇸美国1号-0.1倍, 🇺🇸美国2号-0.1倍, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍', 🇹🇼台湾, 🇫🇷法国巴黎, 🇫🇷法国巴黎2, 🇫🇷法国马赛, 🇳🇱荷兰阿姆斯特丹, 🇩🇪德国法兰克福2, 🇬🇧英国伦敦, 🇬🇧英国伦敦2, 🇮🇳印度海得拉巴, 🇯🇵日本, 🇯🇵日本2] }
+    - { name: 自动选择, type: url-test, proxies: ['剩余流量:855.2 GB', 套餐到期:长期有效, 🇺🇸美国凤凰城专线1, 🇺🇸美国凤凰城专线2, 🇦🇺澳大利亚悉尼, 🇦🇺澳大利亚悉尼2, 🇳🇱荷兰专线, 🇯🇵日本专线, 🇯🇵日本专线3, 🇯🇵日本专线4, 🇸🇬新加坡专线, 🇸🇬新加坡专线2, 🇺🇸美国洛杉矶专线, 🇺🇸美国洛杉矶专线2, 🇺🇸美国洛杉矶专线3, 🇺🇸美国洛杉矶专线4, 🇰🇷韩国三网专线, 🇰🇷韩国三网专线2, 🇰🇷韩国三网专线3, 🇰🇷韩国专线2, 🇧🇷巴西专线, 🇸🇬亚马逊新加坡, 🇮🇳印度孟买专线2, 🇺🇸美国高速01, 🇺🇸美国高速02, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍率', 🇭🇰香港1号, 🇭🇰香港2号, 🇭🇰香港3号, 🇭🇰香港4号, 🇺🇸美国1号-0.1倍, 🇺🇸美国2号-0.1倍, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍', 🇹🇼台湾, 🇫🇷法国巴黎, 🇫🇷法国巴黎2, 🇫🇷法国马赛, 🇳🇱荷兰阿姆斯特丹, 🇩🇪德国法兰克福2, 🇬🇧英国伦敦, 🇬🇧英国伦敦2, 🇮🇳印度海得拉巴, 🇯🇵日本, 🇯🇵日本2], url: 'http://www.gstatic.com/generate_204', interval: 86400 }
+    - { name: 故障转移, type: fallback, proxies: ['剩余流量:855.2 GB', 套餐到期:长期有效, 🇺🇸美国凤凰城专线1, 🇺🇸美国凤凰城专线2, 🇦🇺澳大利亚悉尼, 🇦🇺澳大利亚悉尼2, 🇳🇱荷兰专线, 🇯🇵日本专线, 🇯🇵日本专线3, 🇯🇵日本专线4, 🇸🇬新加坡专线, 🇸🇬新加坡专线2, 🇺🇸美国洛杉矶专线, 🇺🇸美国洛杉矶专线2, 🇺🇸美国洛杉矶专线3, 🇺🇸美国洛杉矶专线4, 🇰🇷韩国三网专线, 🇰🇷韩国三网专线2, 🇰🇷韩国三网专线3, 🇰🇷韩国专线2, 🇧🇷巴西专线, 🇸🇬亚马逊新加坡, 🇮🇳印度孟买专线2, 🇺🇸美国高速01, 🇺🇸美国高速02, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍率', 🇭🇰香港1号, 🇭🇰香港2号, 🇭🇰香港3号, 🇭🇰香港4号, 🇺🇸美国1号-0.1倍, 🇺🇸美国2号-0.1倍, '🇳🇱荷兰Eygelshoven | BT下载-0.1倍', 🇹🇼台湾, 🇫🇷法国巴黎, 🇫🇷法国巴黎2, 🇫🇷法国马赛, 🇳🇱荷兰阿姆斯特丹, 🇩🇪德国法兰克福2, 🇬🇧英国伦敦, 🇬🇧英国伦敦2, 🇮🇳印度海得拉巴, 🇯🇵日本, 🇯🇵日本2], url: 'http://www.gstatic.com/generate_204', interval: 7200 }
+rules:
+    - 'DOMAIN-SUFFIX,services.googleapis.cn,一分机场'
+    - 'DOMAIN-SUFFIX,xn--ngstr-lra8j.com,一分机场'
+    - 'DOMAIN,safebrowsing.urlsec.qq.com,DIRECT'
+
+```
+PUT /subscriptions 应该是实现 update if not exists 
+GET /subscriptions 应该是实现这些链接对应的 yaml 文件的读取。
+GET /subscriptions?update=true 应该是再次调用 get_sub 、 save_path ,然后返回新的 yaml 文件
+
+
+routers\subscriptions.py  中  SUBSCRIPTIONS_FILE = settings.PATH_CONFIG_DIR / "app.yaml" 并未能恰当的保存链接的元数据,每次请求,都会提示链接存在,但是实际 output\subscriptions 目录下的 yaml 并不存在。并且 app.yaml 中也没有任何元数据信息。
+
+我提供判断链接是否存在的方法:首先要从文件层面读取 app.yaml ,得到记录的元数据再判断。或者写成一个接口函数,每次读写都要有一个完整的过程。
+
+app.yaml 存在元数据后,根据元数据的信息,判断 output\subscriptions 目录下的 yaml 是否存在,最终才能确定链接及其配置文件是否保存。最终才能返回 API 。
+
+其次,每次请求多个 POST /subscriptions "urls" 都会因为某个订阅链接失败而导致下一个链接无法保存,这是不被允许的,错误的链接你也应该通过接口告知。成功的链接也需要返回其元数据。即:urls 有3个订阅链接,你都要返回3个的最终信息。
+
+现在Response返回第2个结果是错的,它没有保存到backend\\output\\subscriptions  .yaml 文件中
+[
+  {
+    "url": "http://sub.sub.sub.subsub123456789.com/answer/land?token=a7cbdde987a58068b82e52d57ee5eecd",
+    "file_path": "g:\\code\\upwork\\zhang_crawl_bio\\local_proxy_pool\\backend\\output\\subscriptions\\subscription_d92ef962dad83e2caa45b0082db318b6.yaml",
+    "created_at": "2025-01-26T05:08:24.654698",
+    "updated_at": "2025-01-27T04:23:02.069518",
+    "last_check": "2025-01-27T04:23:02.069518",
+    "status": "inactive",
+    "traffic_used": 0,
+    "traffic_total": 0,
+    "group": null,
+    "file_exists": true,
+    "last_error": null,
+    "retry_count": 0,
+    "next_retry": null
+  },
+  {
+    "url": "https://www.yfjc.xyz/api/v1/client/subscribe?token=b74f2207492053926f7511a8e474048f",
+    "file_path": "g:\\code\\upwork\\zhang_crawl_bio\\local_proxy_pool\\backend\\output\\subscriptions\\string.yaml",
+    "created_at": "2025-01-26T21:47:39.144325",
+    "updated_at": "2025-01-26T21:47:39.144325",
+    "last_check": null,
+    "status": "active",
+    "traffic_used": 0,
+    "traffic_total": 0,
+    "group": null,
+    "file_exists": false,
+    "last_error": null,
+    "retry_count": 0,
+    "next_retry": null
+  },
+  {
+    "url": "http://subscr.xpoti.com/v3/subscr?id=90bd6ff1da374a89b4ba763f354f20bf",
+    "file_path": "",
+    "created_at": "2025-01-27T05:00:59.463719",
+    "updated_at": "2025-01-27T05:00:59.463719",
+    "last_check": null,
+    "status": "error",
+    "traffic_used": 0,
+    "traffic_total": 0,
+    "group": null,
+    "file_exists": false,
+    "last_error": "",
+    "retry_count": 0,
+    "next_retry": null
+  }
+]

+ 87 - 0
backend/routers/subscriptions.py

@@ -0,0 +1,87 @@
+import asyncio
+import hashlib
+from pathlib import Path
+from fastapi import APIRouter, HTTPException
+from datetime import datetime, timedelta
+from pydantic import BaseModel
+from typing import Dict, List, Optional
+import httpx
+import yaml
+from config.logu import logger, get_logger
+from config.settings import settings
+from config.app_yaml import app_yaml, Subscription
+from utils.sub import async_get_sub
+router = APIRouter()
+
+
+class SubscriptionBatchRequest(BaseModel):
+    urls: List[str]
+
+class SubscriptionResponse(BaseModel):
+    file_name: str
+    provider_name: str
+    updated_at: datetime
+    proxies: List[Dict]
+async def process_url(url: str, save_path) -> Subscription:
+    """处理单个URL的异步任务"""
+    try:
+        save_path_res = await async_get_sub(
+            url, 
+            save_path,
+            timeout=5
+        )
+        return Subscription(
+            url=url, 
+            file_path=str(save_path_res), 
+            updated_at=datetime.now(), 
+            error=0, 
+            detail={"msg": "更新订阅成功"}
+        )
+    except Exception as e:
+        logger.error(f"更新订阅失败: {url}, 错误: {str(e)}")
+        return Subscription(
+            url=url, 
+            file_path="", 
+            updated_at=datetime.now(), 
+            error=1, 
+            detail={"msg": f"更新订阅失败: {str(e)}"}
+        )
+
+@router.post("/")
+async def add_subscriptions(sub: SubscriptionBatchRequest):
+    """批量更新订阅链接"""
+    logger.info(f"开始批量更新订阅: {sub.urls}")
+    # 初始化任务列表
+    tasks = []
+    # 并发处理所有URL
+    for url in sub.urls:
+        save_path = settings.PATH_SUBSCRIPTION_DIR / f"{hashlib.md5(url.encode()).hexdigest()[:8]}.yaml"
+        # 将每个URL的异步任务添加到任务列表中
+        tasks.append(process_url(url, save_path))
+    results = await asyncio.gather(*tasks)
+    app_yaml.subscriptions = results
+    app_yaml.save()
+    return results
+
+@router.get("/")
+async def list_subscriptions() ->List[SubscriptionResponse]:
+    ret = []
+    for sub in app_yaml.subscriptions:
+        with open(sub.file_path, "r",encoding='utf-8') as f:
+            sub_yaml = yaml.safe_load(f)
+        groups = sub_yaml.get("proxy-groups", [])
+        if not groups:
+            continue
+        name = groups[0].get("name", "")
+        if not name:
+            continue
+        ret.append(
+            SubscriptionResponse(
+                file_name=Path(sub.file_path).name,
+                provider_name=name,
+                updated_at=sub.updated_at,
+                proxies=sub_yaml.get("proxies", []),
+
+            )
+        )
+    return ret

+ 1 - 0
backend/tests/.gitignore

@@ -0,0 +1 @@
+mytest

+ 45 - 0
backend/utils/mihomo_service.py

@@ -0,0 +1,45 @@
+import httpx
+from config.settings import settings
+from pathlib import Path
+import os
+from contextlib import closing
+from socket import socket, AF_INET, SOCK_STREAM
+def find_free_port(scope=(9350, 18000)):
+    """
+    查找一个可用端口
+    :param scope: 指定端口范围,为None时使用默认范围(9600-19600)
+    :return: 可以使用的端口号
+    """
+
+    for port in range(*scope):
+        with closing(socket(AF_INET, SOCK_STREAM)) as sock:
+            try:
+                # 尝试绑定端口,如果成功则说明端口空闲
+                sock.bind(('127.0.0.1', port))
+                return port
+            except OSError:
+                # 端口已被占用,继续尝试下一个
+                continue
+
+    raise OSError('未找到可用端口。')
+
+def port_is_using(ip, port):
+    """检查端口是否被占用"""
+    with closing(socket(AF_INET, SOCK_STREAM)) as sock:
+        sock.settimeout(.1)
+        result = sock.connect_ex((ip, int(port)))
+        return result == 0
+
+async def download_mihomo():
+    """下载mihomo可执行文件"""
+    if settings.MIHOMO_BIN_PATH.exists():
+        return
+    
+    try:
+        resp = httpx.get(settings.MIHOMO_DOWNLOAD_URL, timeout=30)
+        resp.raise_for_status()
+        settings.MIHOMO_BIN_PATH.parent.mkdir(parents=True, exist_ok=True)
+        settings.MIHOMO_BIN_PATH.write_bytes(resp.content)
+        settings.MIHOMO_BIN_PATH.chmod(0o755)
+    except Exception as e:
+        raise RuntimeError(f"下载mihomo失败: {str(e)}")

+ 76 - 0
backend/utils/sub.py

@@ -0,0 +1,76 @@
+import httpx
+import yaml
+from pathlib import Path
+from typing import Optional, Dict, Any
+from fastapi import HTTPException
+from config.app_yaml import save_yaml_dump 
+
+async def async_get_sub(sub_url: str, save_path: Path, timeout: int = 10) -> Path:
+    """获取订阅文件"""
+    headers = {'User-Agent': 'clash-verge/v1.7.5'}
+    try:
+        async with httpx.AsyncClient() as client:
+            resp = await client.get(sub_url, headers=headers, follow_redirects=True, timeout=timeout)
+            resp.raise_for_status()
+    except httpx.HTTPError as e:
+        raise HTTPException(status_code=500, detail=f"订阅获取失败: {str(e)}")
+    
+    save_path = Path(save_path)
+    save_path.parent.mkdir(parents=True, exist_ok=True)
+    
+    with open(save_path, 'w', encoding='utf-8') as f:
+        f.write(resp.text)
+    return save_path
+
+def get_sub(sub_url: str, save_path: Path) -> Path:
+    """获取订阅文件"""
+    headers = {'User-Agent': 'clash-verge/v1.7.5'}
+    try:
+        resp = httpx.get(sub_url, headers=headers, follow_redirects=True, timeout=10)
+        resp.raise_for_status()
+    except httpx.HTTPError as e:
+        raise HTTPException(status_code=500, detail=f"订阅获取失败: {str(e)}")
+    
+    save_path = Path(save_path)
+    save_path.parent.mkdir(parents=True, exist_ok=True)
+    
+    with open(save_path, 'w', encoding='utf-8') as f:
+        f.write(resp.text)
+    return save_path
+
+def update_config(
+    read_path: Path,
+    config_update: dict,
+    save_as: Optional[Path] = None,
+) -> Path:
+    """更新配置文件"""
+    config: Dict[str, Any] = {}
+    if read_path.exists():
+        with open(read_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f) or {}
+    
+    config.update(config_update)
+    
+    save_as = save_as or read_path
+    save_yaml_dump(config, save_as)
+
+def main():
+    sub_url = 'https://www.yfjc.xyz/api/v1/client/subscribe?token=b74f2207492053926f7511a8e474048f'
+    OUTPUT = Path(__file__).parent.parent.absolute() / "output"
+    save_path = get_sub(sub_url, OUTPUT / "config.yaml")
+    update_config(
+        read_path=save_path,
+        config_update={
+            "port": 7890,
+            "socks-port": 7891,
+            "redir-port": 7892,
+            "allow-lan": True,
+            "mode": "rule",
+            "log-level": "silent",
+            "external-controller": "127.0.0.1:9090",
+            "secret": "",
+        }
+    )
+
+if __name__ == "__main__":
+    main()

+ 278 - 0
backend/uv.lock

@@ -0,0 +1,278 @@
+version = 1
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "sniffio" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
+]
+
+[[package]]
+name = "backend"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "fastapi" },
+    { name = "httpx" },
+    { name = "loguru" },
+    { name = "pydantic" },
+    { name = "pydantic-settings" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.115.7" },
+    { name = "httpx", specifier = ">=0.28.1" },
+    { name = "loguru", specifier = ">=0.7.3" },
+    { name = "pydantic", specifier = ">=2.10.6" },
+    { name = "pydantic-settings", specifier = ">=2.7.1" },
+    { name = "uvicorn", specifier = ">=0.34.0" },
+]
+
+[[package]]
+name = "certifi"
+version = "2024.12.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
+]
+
+[[package]]
+name = "click"
+version = "8.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.115.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/f5/3f921e59f189e513adb9aef826e2841672d50a399fead4e69afdeb808ff4/fastapi-0.115.7.tar.gz", hash = "sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015", size = 293177 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e6/7f/bbd4dcf0faf61bc68a01939256e2ed02d681e9334c1a3cef24d5f77aba9f/fastapi-0.115.7-py3-none-any.whl", hash = "sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e", size = 94777 },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.10.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 },
+    { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 },
+    { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 },
+    { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 },
+    { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 },
+    { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 },
+    { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 },
+    { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 },
+    { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 },
+    { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 },
+    { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 },
+    { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
+    { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
+    { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
+    { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
+    { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
+    { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
+    { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
+    { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
+    { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
+    { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
+    { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
+    { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
+    { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
+    { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
+    { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
+    { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
+    { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "python-dotenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
+]
+
+[[package]]
+name = "starlette"
+version = "0.45.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.34.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
+]