This commit is contained in:
riglen
2026-04-09 11:17:06 +08:00
parent 4e97dbc369
commit 5c86136912
4 changed files with 49 additions and 1 deletions

View File

@@ -15,6 +15,9 @@ PUBLIC_BASE_URL=
# 1. Clash/Mihomo YAML proxies 文件 # 1. Clash/Mihomo YAML proxies 文件
# 2. base64 编码 URI 订阅 # 2. base64 编码 URI 订阅
# 3. 直接明文 URI 订阅 # 3. 直接明文 URI 订阅
# 也支持本地文件,例如:
# AIRPORT_B_URL=/app/data/sources/airport-b.txt
# AIRPORT_B_URL=file:///app/data/sources/airport-b.txt
AIRPORT_A_URL= AIRPORT_A_URL=
# 允许留空;留空时该机场会自动跳过,不参与默认查询 # 允许留空;留空时该机场会自动跳过,不参与默认查询
AIRPORT_B_URL= AIRPORT_B_URL=

View File

@@ -48,6 +48,7 @@ sub-provider/
cn-ip.yaml cn-ip.yaml
data/ data/
cache/ cache/
sources/
.env.example .env.example
Dockerfile Dockerfile
docker-compose.yaml docker-compose.yaml
@@ -69,6 +70,7 @@ cp .env.example .env
- `PUBLIC_PATH` 改成足够长的随机字符串 - `PUBLIC_PATH` 改成足够长的随机字符串
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com` - `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
- `AIRPORT_A_URL` / `AIRPORT_B_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅 - `AIRPORT_A_URL` / `AIRPORT_B_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt``file:///app/data/sources/airport-b.txt`
- 允许把其中一个留空;留空时这个机场会自动跳过 - 允许把其中一个留空;留空时这个机场会自动跳过
3. 启动: 3. 启动:
@@ -148,6 +150,7 @@ HEAD /<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b
- 生成后的完整 YAML 会缓存到 `output/bundle-cache/`,默认 600 秒内直接返回缓存 - 生成后的完整 YAML 会缓存到 `output/bundle-cache/`,默认 600 秒内直接返回缓存
- 可用 `force_refresh=true` 强制跳过缓存并覆盖旧缓存文件 - 可用 `force_refresh=true` 强制跳过缓存并覆盖旧缓存文件
- 上游订阅原始内容也会按 TTL 落盘缓存到 `data/fetch-cache/`,默认 900 秒 - 上游订阅原始内容也会按 TTL 落盘缓存到 `data/fetch-cache/`,默认 900 秒
- 如果你想固定使用本地文件订阅,可以把文件放到 `data/sources/`,再把 `AIRPORT_*_URL` 指向 `/app/data/sources/xxx.txt`
--- ---

View File

@@ -4,13 +4,14 @@ import base64
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
import httpx import httpx
import yaml import yaml
from app.config import get_settings from app.config import ROOT_DIR, get_settings
from app.models import FetchResult, ProviderDocument, SourceConfig, SourceSnapshot from app.models import FetchResult, ProviderDocument, SourceConfig, SourceSnapshot
from app.services.cache import TTLCache from app.services.cache import TTLCache
from app.services.fetch_cache import build_fetch_cache_key, load_fetch_cache, save_fetch_cache from app.services.fetch_cache import build_fetch_cache_key, load_fetch_cache, save_fetch_cache
@@ -31,6 +32,17 @@ async def fetch_source(name: str, source: SourceConfig) -> FetchResult:
logger.info("fetch_source memory cache hit: source=%s ttl=%s", name, ttl) logger.info("fetch_source memory cache hit: source=%s ttl=%s", name, ttl)
return cached return cached
local_path = resolve_local_source_path(source.url)
if local_path is not None:
logger.info("fetch_source local file: source=%s path=%s", name, local_path)
try:
result = FetchResult(text=local_path.read_text(encoding="utf-8"), headers={})
except Exception:
logger.exception("fetch_source local file read failed: source=%s path=%s", name, local_path)
raise
_fetch_cache.set(name, result, ttl)
return result
cache_key = build_fetch_cache_key(name=name, url=source.url) cache_key = build_fetch_cache_key(name=name, url=source.url)
disk_cached = load_fetch_cache( disk_cached = load_fetch_cache(
cache_dir=settings.fetch_cache_dir, cache_dir=settings.fetch_cache_dir,
@@ -66,6 +78,34 @@ async def fetch_source(name: str, source: SourceConfig) -> FetchResult:
return result return result
def resolve_local_source_path(raw: str) -> Path | None:
candidate = raw.strip()
if not candidate:
return None
parsed = urlparse(candidate)
if parsed.scheme in {"http", "https"}:
return None
if parsed.scheme == "file":
path_text = unquote(parsed.path or "")
if parsed.netloc:
path_text = f"//{parsed.netloc}{path_text}"
if re.match(r"^/[A-Za-z]:", path_text):
path_text = path_text[1:]
path = Path(path_text)
else:
path = Path(candidate)
if not path.is_absolute():
path = ROOT_DIR / path
resolved = path.resolve()
if not resolved.is_file():
raise FileNotFoundError(f"Local source file not found: {resolved}")
return resolved
async def build_provider_document(name: str, source: SourceConfig) -> ProviderDocument: async def build_provider_document(name: str, source: SourceConfig) -> ProviderDocument:
settings = get_settings() settings = get_settings()
ttl = source.cache_ttl_seconds or settings.cache_ttl_seconds ttl = source.cache_ttl_seconds or settings.cache_ttl_seconds

View File

@@ -15,6 +15,8 @@ sources:
display_name: B display_name: B
kind: auto kind: auto
url: ${AIRPORT_B_URL} url: ${AIRPORT_B_URL}
headers:
User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
prefix: "[B] " prefix: "[B] "
include_regex: "" include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐" exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"