Merge pull request #924 from wumode/tobypasstrackers

This commit is contained in:
jxxghp
2025-11-20 17:39:24 +08:00
committed by GitHub
8 changed files with 567 additions and 322 deletions

View File

@@ -437,11 +437,12 @@
"name": "绕过Trackers",
"description": "提供tracker服务器IP地址列表帮助IPv6连接绕过OpenClash。",
"labels": "工具",
"version": "1.4.3",
"version": "1.5.0",
"icon": "Clash_A.png",
"author": "wumode",
"level": 2,
"history": {
"v1.5.0": "新增 Page 界面; 支持通过`/check_ip` 命令检查IP; 改进 UI",
"v1.4.3": "修复 bug",
"v1.4.2": "修复插件动作",
"v1.4.1": "修复通知类型错误",
@@ -491,12 +492,13 @@
"name": "Clash Rule Provider",
"description": "随时为Clash添加一些额外的规则。",
"labels": "工具",
"version": "2.0.9",
"version": "2.0.10",
"icon": "Mihomo_Meta_A.png",
"author": "wumode",
"level": 1,
"release": true,
"history": {
"v2.0.10": "适配 MoviePilot 2.8.4",
"v2.0.9": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v2.0.8": "修复已知问题",
"v2.0.7": "修复子规则比较错误",
@@ -532,11 +534,12 @@
"name": "美剧生词标注",
"description": "根据CEFR等级为英语影视剧标注高级词汇。",
"labels": "英语",
"version": "1.1.3",
"version": "1.1.4",
"icon": "LexiAnnot.png",
"author": "wumode",
"level": 1,
"history": {
"v1.1.4": "优化字幕选择决策",
"v1.1.3": "适配 Pydantic V2 (主程序版本需高于 2.8.1-1)",
"v1.1.2": "使用子进程避免 spaCy 模型常驻内存",
"v1.1.1": "添加任务页面; 改进 spaCy 模型加载逻辑",

View File

@@ -9,11 +9,10 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from pydantic import ValidationError
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.event import eventmanager, Event
from app.log import logger
from app.schemas.types import EventType, NotificationType
from app.scheduler import Scheduler
from .api import ClashRuleProviderApi, apis
from .base import _ClashRuleProviderBase
@@ -32,7 +31,7 @@ class ClashRuleProvider(_ClashRuleProviderBase):
# 插件图标
plugin_icon = "Mihomo_Meta_A.png"
# 插件版本
plugin_version = "2.0.9"
plugin_version = "2.0.10"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -92,11 +91,7 @@ class ClashRuleProvider(_ClashRuleProviderBase):
self.state.ruleset_rules_manager.clear()
if ClashRuleProvider.event_loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = Scheduler().loop
ClashRuleProvider.event_loop = loop
ClashRuleProvider.event_loop = global_vars.loop
self.scheduler = AsyncIOScheduler(timezone=settings.TZ, event_loop=ClashRuleProvider.event_loop)
self.services = ClashRuleProviderService(self.__class__.__name__, self.config, self.state, self.store,
self.scheduler)

View File

@@ -83,7 +83,7 @@ class LexiAnnot(_PluginBase):
# 插件图标
plugin_icon = "LexiAnnot.png"
# 插件版本
plugin_version = "1.1.3"
plugin_version = "1.1.4"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -109,7 +109,7 @@ class LexiAnnot(_PluginBase):
_context_window: int = 0
_max_retries: int = 0
_request_interval: int = 0
_ffmpeg_path = ''
_ffmpeg_path: str = 'ffmpeg'
_english_only = False
_when_file_trans = False
_model_temperature = ''
@@ -154,7 +154,7 @@ class LexiAnnot(_PluginBase):
self._context_window = int(config.get("context_window") or 10)
self._max_retries = int(config.get("max_retries") or 3)
self._request_interval = int(config.get("request_interval") or 3)
self._ffmpeg_path = config.get("ffmpeg_path")
self._ffmpeg_path = config.get("ffmpeg_path") or 'ffmpeg'
self._english_only = config.get("english_only")
self._when_file_trans = config.get("when_file_trans")
self._model_temperature = config.get("model_temperature") or '0.3'
@@ -975,31 +975,23 @@ class LexiAnnot(_PluginBase):
},
'content': [
{
'component': 'VRow',
'component': 'VCol',
'props': {
'class': 'd-none d-sm-block',
'cols': 12,
},
'content': [
{
'component': 'VCol',
'component': 'VDataTableVirtual',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VDataTableVirtual',
'props': {
'class': 'text-sm',
'headers': headers,
'items': items,
'height': '30rem',
'density': 'compact',
'fixed-header': True,
'hide-no-data': True,
'hover': True
}
}
]
'class': 'text-sm',
'headers': headers,
'items': items,
'height': '30rem',
'density': 'compact',
'fixed-header': True,
'hide-no-data': True,
'hover': True
}
}
]
}
@@ -1213,7 +1205,11 @@ class LexiAnnot(_PluginBase):
embedded_subtitles = LexiAnnot._extract_subtitles_by_lang(path, eng_mark, ffmpeg_path)
if not embedded_subtitles:
return TaskStatus.CANCELED
embedded_subtitles = sorted(embedded_subtitles, key=lambda track: 'SDH' in track['title'])
# order factor = 0, if 'SDH' in track['title']
# order factor = track['duration'], otherwise
embedded_subtitles = sorted(embedded_subtitles,
key=lambda track: track['duration']*(1-int('SDH' in track['title'])),
reverse=True)
ret_message = ''
if embedded_subtitles:
logger.info(f'提取到 {len(embedded_subtitles)} 条英语文本字幕')
@@ -1705,7 +1701,8 @@ class LexiAnnot(_PluginBase):
return None
@staticmethod
def _extract_subtitles_by_lang(video_path: str, lang: str | list = 'en', ffmpeg: str = 'ffmpeg') -> Optional[List[Dict]]:
def _extract_subtitles_by_lang(video_path: str, lang: str | list = 'en', ffmpeg: str = 'ffmpeg'
) -> Optional[List[Dict]]:
"""
提取视频文件中的内嵌英文字幕,使用 MediaInfo 查找字幕流。
"""
@@ -1720,12 +1717,19 @@ class LexiAnnot(_PluginBase):
try:
media_info: pymediainfo.MediaInfo = pymediainfo.MediaInfo.parse(video_path)
for track in media_info.tracks:
if track.track_type == 'Text' and check_lang(track_lang=track.language) and track.codec_id in supported_codec:
if (track.track_type == 'Text' and check_lang(track_lang=track.language)
and track.codec_id in supported_codec):
subtitle_stream_index = track.stream_identifier # MediaInfo 的 stream_id 从 1 开始ffmpeg 从 0 开始
subtitle = LexiAnnot.__extract_subtitle(video_path, subtitle_stream_index, ffmpeg)
duration = 0
if hasattr(track, 'duration'):
try:
duration = int(float(track.duration))
except (ValueError, TypeError):
pass
if subtitle:
subtitles.append({'title': track.title or '', 'subtitle': subtitle, 'codec_id': track.codec_id,
'stream_id': subtitle_stream_index})
'stream_id': subtitle_stream_index, 'duration': duration})
if subtitles:
return subtitles
else:
@@ -1761,7 +1765,7 @@ class LexiAnnot(_PluginBase):
)
if not response.success:
logger.warning(f"Error in subprocess response: {response.message}")
logger.warning(f"Error in response: {response.message}")
return tasks.tasks
self._total_token_count += response.total_token_count
@@ -1918,7 +1922,7 @@ Only complete the `Chinese` field. Do not include pinyin, explanations, or any a
)
i = 0
dialog_trans_instruction = '''You are an expert translator. You will be given a list of dialogue translation tasks in JSON format. For each entry, provide the most appropriate translation in Simplified Chinese based on the context.
Only complete the `Chinese` field. Do not include pinyin, explanations, or any additional information.'''
Only complete the `Chinese` field. Do not include pinyin, explanations, or any additional information.'''
while i < len(translation_tasks):
if self._shutdown_event.is_set():
return lines_to_process
@@ -2044,4 +2048,8 @@ Only complete the `Chinese` field. Do not include pinyin, explanations, or any a
if chinese and chinese[-1] in ['', '']:
chinese = chinese[:-1]
main_dialogue[line_data['index']].text = main_dialogue[line_data['index']].text + f"\\N{chinese}"
# 避免 Infuse 显示乱码
unexplainable_line = pysubs2.SSAEvent(start=0, end=0, text=f"{{\\rAnnotation ZH}}{self.plugin_name}{{\\r}}")
ass_file.insert(0, unexplainable_line)
return ass_file

View File

@@ -68,13 +68,14 @@ def translate(
returns: GeminiResponse containing the results
"""
client = genai.Client(api_key=api_key)
messages = []
response_schema = type(translation_tasks)
for attempt in range(1, max_retries + 1):
try:
client = genai.Client(api_key=api_key)
response = client.models.generate_content(
model=gemini_model,
contents=translation_tasks.model_dump_json(),
@@ -100,7 +101,7 @@ def translate(
except Exception as e:
messages.append(f"Attempt {attempt} failed: {str(e)}")
if attempt < max_retries:
time.sleep(retry_delay)
time.sleep(attempt*retry_delay)
return GeminiResponse(
tasks=[],

View File

@@ -3,22 +3,48 @@ import base64
import ipaddress
import json
import socket
import time
from datetime import datetime, timedelta
from typing import Any, List, Dict, Tuple, Optional
from ipaddress import IPv4Network, IPv6Network, IPv4Address, IPv6Address
from typing import Any, List, Dict, Tuple, Optional, Literal, overload
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import Response
from pydantic import BaseModel, Field
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.db.site_oper import SiteOper
from app.log import logger
from app.plugins import _PluginBase
from app.plugins.tobypasstrackers.dns_helper import DnsHelper
from app.schemas.types import EventType, NotificationType
from app.utils.http import RequestUtils
from .dns_helper import DnsHelper
class IpCidrItem(BaseModel):
# IP CIDR
ip_cidr: str
# 解析时间
timestamp: int = Field(default=0)
# DNS
nameserver: str | None = Field(default=None)
# 域名
domain: str | None = Field(default=None)
def to_dict(self) -> dict:
if self.timestamp:
dns_time = datetime.fromtimestamp(int(self.timestamp)).strftime("%Y-%m-%d %H:%M:%S")
else:
dns_time = '-'
return {
'ip_cidr': self.ip_cidr,
'domain': self.domain or '',
'nameserver': self.nameserver or '-',
'datetime': dns_time
}
class ToBypassTrackers(_PluginBase):
@@ -29,7 +55,7 @@ class ToBypassTrackers(_PluginBase):
# 插件图标
plugin_icon = "Clash_A.png"
# 插件版本
plugin_version = "1.4.3"
plugin_version = "1.5.0"
# 插件作者
plugin_author = "wumode"
# 作者主页
@@ -40,13 +66,15 @@ class ToBypassTrackers(_PluginBase):
plugin_order = 21
# 可使用的用户级别
auth_level = 2
# CN IP lists
chn_route6_lists_url = "https://ispip.clang.cn/all_cn_ipv6.txt"
chn_route_lists_url = "https://ispip.clang.cn/all_cn.txt"
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
# 开关
_enabled: bool = False
_cron: str = ""
_notify = False
_notify: bool = False
_onlyonce: bool = False
_custom_trackers: str = ""
_exempted_domains: str = ""
@@ -55,20 +83,16 @@ class ToBypassTrackers(_PluginBase):
_china_ipv6_route: bool = True
_bypass_ipv4: bool = True
_bypass_ipv6: bool = True
_dns_input: str = ""
ipv6_txt: str = ""
ipv4_txt: str = ""
_dns_input: str | None = None
trackers: Dict[str, List[str]] = {}
def init_plugin(self, config: dict = None):
self.stop_service()
self.trackers = {}
self.ipv6_txt = self.get_data("ipv6_txt") if self.get_data("ipv6_txt") else ""
self.ipv4_txt = self.get_data("ipv4_txt") if self.get_data("ipv4_txt") else ""
try:
site_file = settings.ROOT_PATH/'app'/'plugins'/'tobypasstrackers'/'sites'/'trackers'
site_file = settings.ROOT_PATH/'app'/'plugins'/self.__class__.__name__.lower()/'sites'/'trackers'
with open(site_file, "r", encoding="utf-8") as f:
base64_str = f.read()
self.trackers = json.loads(base64.b64decode(base64_str).decode("utf-8"))
@@ -76,18 +100,18 @@ class ToBypassTrackers(_PluginBase):
logger.error(f"插件加载错误:{e}")
# 配置
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._onlyonce = config.get("onlyonce")
self._notify = config.get("notify")
self._custom_trackers = config.get("custom_trackers")
self._exempted_domains = config.get("exempted_domains")
self._enabled = bool(config.get("enabled"))
self._cron = config.get("cron") or "0 4 * * *"
self._onlyonce = bool(config.get("onlyonce"))
self._notify = bool(config.get("notify"))
self._custom_trackers = config.get("custom_trackers") or ""
self._exempted_domains = config.get("exempted_domains") or ""
self._bypassed_sites = config.get("bypassed_sites") or []
self._bypass_ipv4 = config.get("bypass_ipv4")
self._bypass_ipv6 = config.get("bypass_ipv6")
self._dns_input = config.get("dns_input")
self._china_ipv6_route = config.get("china_ipv6_route")
self._china_ip_route = config.get("china_ip_route")
self._bypass_ipv4 = bool(config.get("bypass_ipv4"))
self._bypass_ipv6 = bool(config.get("bypass_ipv6"))
self._dns_input: str | None = config.get("dns_input")
self._china_ipv6_route = bool(config.get("china_ipv6_route"))
self._china_ip_route = bool(config.get("china_ip_route"))
# 过滤掉已删除的站点
all_sites = [site.id for site in SiteOper().list_order_by_pri()]
self._bypassed_sites = [site_id for site_id in all_sites if site_id in self._bypassed_sites]
@@ -95,7 +119,7 @@ class ToBypassTrackers(_PluginBase):
if self._enabled or self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._onlyonce:
logger.info(f"立即运行一次")
logger.info("立即运行一次")
self._scheduler.add_job(self.update_ips, "date",
run_date=datetime.now(
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
@@ -128,14 +152,24 @@ class ToBypassTrackers(_PluginBase):
@staticmethod
def get_command() -> List[Dict[str, Any]]:
return [{
"cmd": "/refresh_tracker_ips",
"event": EventType.PluginAction,
"desc": "更新 Tracker IP 列表",
"data": {
"action": "refresh_tracker_ips"
return [
{
"cmd": "/refresh_tracker_ips",
"event": EventType.PluginAction,
"desc": "更新 Tracker IP 列表",
"data": {
"action": "refresh_tracker_ips"
}
},
{
"cmd": "/check_ip",
"event": EventType.PluginAction,
"desc": "检测 IP 是否在绕过列表中: /check_ip <域名或IP>",
"data": {
"action": "check_ip"
}
}
}]
]
def get_api(self) -> List[Dict[str, Any]]:
"""
@@ -147,13 +181,15 @@ class ToBypassTrackers(_PluginBase):
"summary": "API说明"
}]
"""
return [{
"path": "/bypassed_ips",
"endpoint": self.bypassed_ips,
"methods": ["GET"],
"summary": "绕过的IP",
"description": "绕过Clash核心的IP地址列表",
}]
return [
{
"path": "/bypassed_ips",
"endpoint": self.bypassed_ips,
"methods": ["GET"],
"summary": "绕过的 IP",
"description": "绕过 Clash 核心的 IP 地址列表",
}
]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
site_options = ([{"title": site.name, "value": site.id}
@@ -261,7 +297,7 @@ class ToBypassTrackers(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'china_ip_route',
'label': '合并中国大陆IPv4列表',
'label': '合并中国大陆 IPv4 列表',
}
}
]
@@ -277,7 +313,7 @@ class ToBypassTrackers(_PluginBase):
'component': 'VSwitch',
'props': {
'model': 'china_ipv6_route',
'label': '合并中国大陆IPv6列表',
'label': '合并中国大陆 IPv6 列表',
}
}
]
@@ -316,7 +352,7 @@ class ToBypassTrackers(_PluginBase):
'props': {
'model': 'dns_input',
'label': 'DNS 服务器',
'placeholder': '留空则使用本地DNS'
'placeholder': '留空则使用本地 DNS'
}
}
]
@@ -357,9 +393,9 @@ class ToBypassTrackers(_PluginBase):
'component': 'VTextarea',
'props': {
'model': 'custom_trackers',
'label': '自定义Tracker服务器',
'label': '自定义 Tracker 服务器',
'rows': 3,
'placeholder': '每行一个域名或IP'
'placeholder': '每行一个域名或 IP'
}
}
]
@@ -375,9 +411,76 @@ class ToBypassTrackers(_PluginBase):
'component': 'VTextarea',
'props': {
'model': 'exempted_domains',
'label': '排除的域名和IP',
'label': '排除的域名和 IP',
'rows': 3,
'placeholder': '每行一个域名或IP'
'placeholder': '每行一个域名或 IP'
}
}
]
}
]
},
{
'component': 'VCard',
'content': [
{
'component': 'VCardItem',
'props': {
'prepend-icon': 'mdi-link-variant',
'title': '订阅 URL',
'subtitle': '请先在 MoviePilot 设置中配置「访问域名」',
'class': 'pb-0'
},
},
{
'component': 'VCardActions',
'props': {
},
'content': [
{
'component': 'VBtn',
'text': 'IPv4',
'props': {
'append-icon': 'mdi-open-in-new',
'href': self.api_url(protocol=4),
'target': '_blank'
},
},
{
'component': 'VBtn',
'text': 'IPv6',
'props': {
'append-icon': 'mdi-open-in-new',
'href': self.api_url(protocol=6),
'target': '_blank'
},
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VAlert',
'props': {
'title': 'DNS 服务器示例',
'border': 'start',
'variant': 'tonal',
'text': '仅填一个: '
'「223.5.5.5」、'
'「[2400:3200::1]:53」、'
'「quic://dns.alidns.com:853」、'
'「https://dns.alidns.com/dns-query」。'
}
}
]
@@ -396,60 +499,13 @@ class ToBypassTrackers(_PluginBase):
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': 'DNS 服务器示例 (仅填一个): '
'「94.140.14.140」、'
'「94.140.14.140:53」、'
'「[2a10:50c0::1:ff]:53」、'
'「https://unfiltered.adguard-dns.com/dns-query」。'
'仅支持UDP和HTTPS方法, 留空使用本地DNS查询'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '【订阅URL】'
f'「IPv4 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=4; '
f'「IPv6 API」: /api/v1/plugin/ToBypassTrackers/bypassed_ips?apikey={settings.API_TOKEN}&protocol=6'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '【如何使用】'
'在「OpenClash->插件设置->中国大陆IP路由」选择「绕过中国大陆」; '
'在「OpenClash->插件设置->Chnroute Update」填入「订阅URL」。'
'color': 'info',
'border': 'start',
'title': '如何使用',
'text': '在「OpenClash->插件设置->流量控制->绕过指定区域 IP」选择「绕过中国大陆」; '
'在「OpenClash->插件设置->大陆白名单订阅」填入「订阅 URL」。'
'使用聊天命令`/check_ip <域名或IP>`检查 IP 是否在绕过列表'
}
}
]
@@ -474,7 +530,95 @@ class ToBypassTrackers(_PluginBase):
}
def get_page(self) -> List[dict]:
pass
headers = [
{'title': 'IP CIDR', 'key': 'ip_cidr', 'sortable': True},
{'title': '域名', 'key': 'domain', 'sortable': True},
{'title': 'DNS', 'key': 'nameserver', 'sortable': True},
{'title': '解析时间', 'key': 'datetime', 'sortable': True},
]
items = [IpCidrItem.model_validate(detail).to_dict()
for detail in (self.get_data("cidr_details") or []) if detail.get('domain') != 'CN']
excluded_items = [IpCidrItem.model_validate(detail).to_dict()
for detail in (self.get_data("excluded_cidr_details") or [])]
return [
{
'component': 'VWindow',
'props': {
'show-arrows': 'hover',
},
'content': [
{
'component': 'VWindowItem',
'content': [
{
'component': 'VCard',
'props': {
'class': 'pa-0',
'title': '绕过的 Tracker 服务器 IP 列表',
'subtitle': '以下是已解析并添加到绕过列表中的 Tracker 服务器 IP 地址,'
'请在 OpenClash 中配置「绕过中国大陆 IP」并订阅本列表以实现绕过效果。',
'variant': 'elevated',
},
'content': [
{
'component': 'VCardText',
'content': [
{
'component': 'VDataTableVirtual',
'props': {
'class': 'text-sm',
'headers': headers,
'items': items,
'height': '30rem',
'density': 'compact',
'fixed-header': True,
'hide-no-data': True,
'hover': True
}
}
]
}
]
}
]
},
{
'component': 'VWindowItem',
'content': [
{
'component': 'VCard',
'props': {
'class': 'mb-4',
'title': '排除的 IP 列表',
'variant': 'elevated',
},
'content': [
{
'component': 'VCardText',
'content': [
{
'component': 'VDataTableVirtual',
'props': {
'class': 'text-sm',
'headers': headers,
'items': excluded_items,
'height': '30rem',
'density': 'compact',
'fixed-header': True,
'hide-no-data': True,
'hover': True
}
}
]
}
]
}
]
},
]
}
]
def get_dashboard(self, key: str = None, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
"""
@@ -491,10 +635,14 @@ class ToBypassTrackers(_PluginBase):
"""
pass
def api_url(self, protocol: int = 4) -> str:
return settings.MP_DOMAIN(f'/api/v1/plugin/{self.__class__.__name__}/bypassed_ips?apikey={settings.API_TOKEN}'
f'&protocol={protocol}')
def stop_service(self):
"""
退出插件
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
@@ -525,77 +673,143 @@ class ToBypassTrackers(_PluginBase):
}]
return []
def bypassed_ips(self, protocol: str) -> Response:
if protocol == '6':
return Response(content=self.ipv6_txt, media_type="text/plain")
return Response(content=self.ipv4_txt, media_type="text/plain")
def bypassed_ips(self, protocol: Literal['4', '6']) -> Response:
data_key = "ipv4_txt" if protocol == '4' else "ipv6_txt"
data = self.get_data(data_key) or ""
return Response(content=data, media_type="text/plain")
@eventmanager.register(EventType.PluginAction)
def update_ips(self, event: Optional[Event]=None):
def __is_ip_in_subnet(ip_input: str, su_bnet: str) -> bool:
"""
Check if the given IP address is in the specified subnet.
def check_ip(self, event: Event):
"""检查 IP 地址 是否在绕过列表"""
event_data = event.event_data
if not event_data or event_data.get("action") != "check_ip":
return
host = event_data.get("arg_str")
channel = event_data.get("channel")
userid = event_data.get("userid")
logger.info(f"检查 IP 是否绕过: {host} (来自用户 {userid},渠道 {channel})")
ip_list, bypassed, excluded = self._check_details(host)
if not ip_list:
self.post_message(channel=channel, user=userid, text=f"无法解析 host: {host}", title=f"{host}")
return
message = ""
for ip in ip_list:
detail = bypassed.get(ip)
excluded_detail = excluded.get(ip)
if ip in excluded and excluded_detail is not None:
detail_msg = '\n'.join(f"{k}: {v}" for k,v in excluded_detail.to_dict().items())
message += f"\nIP 地址 {ip} 在排除列表中,不会被绕过:\n{detail_msg}\n"
elif ip in bypassed and detail is not None:
detail_msg = '\n'.join(f"{k}: {v}" for k,v in detail.to_dict().items())
message += f"\nIP 地址 {ip} 会被绕过:\n{detail_msg}\n"
else:
message += f"\nIP 地址 {ip} 不在绕过列表中。\n"
self.post_message(channel=channel, user=userid, text=message, title=f"{host}")
:param ip_input: IP address as a string (e.g., '192.168.1.1')
:param su_bnet: Subnet in CIDR notation (e.g., '192.168.1.0/24')
:return: True if IP is in the subnet, False otherwise
"""
ip_obj = ipaddress.ip_address(ip_input)
subnet_obj = ipaddress.ip_network(su_bnet, strict=False)
return ip_obj in subnet_obj
@overload
def _load_cn_ip_lists(self, family: type[IPv4Network]) -> list[IPv4Network]: ...
def __search_ip(_ip, ips_list):
i = 0
for ip_range in ips_list:
if __is_ip_in_subnet(_ip, ip_range):
return i
i += 1
return -1
@overload
def _load_cn_ip_lists(self, family: type[IPv6Network]) -> list[IPv6Network]: ...
def __exclude_ip_range(range_b: str, range_a: str):
"""
Exclude IP range A from IP range B and return the remaining subranges.
def _load_cn_ip_lists(self, family: type[IPv4Network] | type[IPv6Network] = IPv4Network
) -> list[IPv4Network | IPv6Network]:
ip_list: list[IPv4Network | IPv6Network] = []
if family is IPv4Network:
url = self.chn_route_lists_url
elif family is IPv6Network:
url = self.chn_route6_lists_url
else:
raise NotImplementedError(f"unknown address family {family}")
res = RequestUtils().get_res(url=url, raise_exception=True)
if res is None or res.status_code != 200:
logger.warn(f"无法获取 CN IP 列表: {url}")
raise ConnectionError
route_list = res.text.strip().split('\n')
for cn_ip_cidr in route_list:
subnet = ipaddress.ip_network(cn_ip_cidr, strict=False)
if isinstance(subnet, family):
ip_list.append(subnet)
return ip_list
:param range_b: The larger IP range in CIDR notation (must include range_a).
:param range_a: The smaller IP range to exclude in CIDR notation.
:return: List of remaining IP subranges in CIDR notation.
"""
net_b = ipaddress.ip_network(range_b, strict=False)
net_a = ipaddress.ip_network(range_a, strict=False)
def _search_details(self, ip_list: list[IPv4Address | IPv6Address], data_key: str) -> dict[str, IpCidrItem | None]:
cidr_details = [IpCidrItem.model_validate(detail) for detail in (self.get_data(data_key) or [])]
ip_cidr_list = [ipaddress.ip_network(item.ip_cidr, strict=False) for item in cidr_details]
details: dict[str, IpCidrItem | None] = {}
for ip in ip_list:
index = ToBypassTrackers._search_ip(ip, ip_cidr_list)
if index == -1:
details[str(ip)] = None
continue
details[str(ip)] = cidr_details[index]
return details
if not (net_a.subnet_of(net_b)):
raise ValueError("Range A is not fully contained within Range B.")
def _check_details(self, host: str) -> tuple[list[str], dict[str, IpCidrItem | None], dict[str, IpCidrItem | None]]:
try:
ip_list = [ipaddress.ip_address(host)]
except ValueError:
dns = DnsHelper(dns_server=self._dns_input)
resolved = asyncio.run(dns.resolve_name(host))
if resolved is None:
return [], {}, {}
ip_list = [ipaddress.ip_address(ip) for ip in resolved]
details = self._search_details(ip_list, "cidr_details")
excluded = self._search_details(ip_list, "excluded_cidr_details")
return [str(ip) for ip in ip_list], details, excluded
remaining_ranges = list(net_b.address_exclude(net_a))
@staticmethod
def _search_ip(ip: IPv4Address | IPv6Address, ips_list: list[IPv4Network | IPv6Network]) -> int:
i = 0
for ip_range in ips_list:
if ip in ip_range:
return i
i += 1
return -1
return [str(sub_net) for sub_net in remaining_ranges]
@staticmethod
def _search_subnet(ip: IPv4Network | IPv6Network, ips_list: list[IPv4Network | IPv6Network]) -> int:
i = 0
for ip_range in ips_list:
if ip.subnet_of(ip_range):
return i
i += 1
return -1
async def resolve_and_check(domain_, results_, failed_msg_, dns_type_, ip_list_):
@eventmanager.register(EventType.PluginAction)
def update_ips(self, event: Optional[Event] = None):
async def resolve_and_check(domain_: str, results_: dict[str, bool], failed_msg_: list[str],
family: int, ip_list_: list[IPv4Network | IPv6Network],
cidr_details_: list[IpCidrItem]):
try:
addresses = await query_helper.query_dns(domain_, dns_type_)
addresses = await query_helper.resolve_name(domain_, family)
if addresses is None:
failed_msg_.append(f"{domain_name_map.get(domain_, domain_)}{domain_}: {dns_type_} 记录查询失败")
dns_type = "AAAA" if family == socket.AF_INET6 else "A"
failed_msg_.append(f"{domain_name_map.get(domain_, domain_)}{domain_}: {dns_type} 记录查询失败")
results_[domain_name_map.get(domain_, domain_)] = False
return
for address in addresses:
has_flag = any(__is_ip_in_subnet(address, subnet) for subnet in ip_list_)
for ip_str in addresses:
ip_obj = ipaddress.ip_address(ip_str)
has_flag = any(ip_obj in sub_net for sub_net in ip_list_)
if not has_flag:
if dns_type_ == "AAAA":
ip_list_.append(address)
else:
ip_list_.append(address)
logger.info(f"Resolving【{domain_name_map.get(domain_, domain_)}{address} ({domain_})")
net_obj = ipaddress.ip_network(ip_obj, strict=False)
ip_list_.append(net_obj)
ip_cidr_item = IpCidrItem(ip_cidr=str(net_obj), domain=domain_,
timestamp=int(time.time()), nameserver=query_helper.nameserver)
cidr_details_.append(ip_cidr_item)
logger.info(f"Resolving【{domain_name_map.get(domain_, domain_)}{ip_str} ({domain_})")
except Exception as e:
logger.exception(f"处理 {domain_} 出错: {e}")
logger.warn(f"处理 {domain_} 出错: {e}")
results_[domain_name_map.get(domain_, domain_)] = False
async def resolve_all(domains_, ipv6_list_, ip_list_):
async def resolve_all(domains_: list[str], ipv6_list_: list[IPv6Network], ip_list_: list[IPv4Network],
details: list[IpCidrItem]):
tasks = [
resolve_and_check(domain_, results_v6, failed_msg, "AAAA", ipv6_list_)
resolve_and_check(domain_, results_v6, failed_msg, socket.AF_INET6, ipv6_list_, details)
for domain_ in domains_
]
tasks.extend([resolve_and_check(domain_, results, failed_msg, "A", ip_list_)
tasks.extend([resolve_and_check(domain_, results, failed_msg, socket.AF_INET, ip_list_, details)
for domain_ in domains_])
await asyncio.gather(*tasks)
@@ -604,29 +818,27 @@ class ToBypassTrackers(_PluginBase):
if not event_data or event_data.get("action") != "refresh_tracker_ips":
return
query_helper = DnsHelper(self._dns_input)
logger.info(f"开始通过 {query_helper.method_name} 解析DNS")
chnroute6_lists_url = "https://ispip.clang.cn/all_cn_ipv6.txt"
chnroute_lists_url = "https://ispip.clang.cn/all_cn.txt"
ipv6_list = []
ip_list = []
logger.info(f"开始通过 {query_helper.nameserver} 解析DNS")
ipv6_list: list[IPv6Network] = []
ip_list: list[IPv4Network] = []
domains = []
success_msg = []
failed_msg = []
results = {}
results: dict[str, bool] = {} # 解析结果
unsupported_msg = []
results_v6 = {}
results_v6: dict[str, bool] = {}
cidr_details: list[IpCidrItem] = []
exempted_cidr_details: list[IpCidrItem] = []
# 加载 CN IP 列表
if self._china_ipv6_route:
# Load Chnroute6 Lists
res = RequestUtils().get_res(url=chnroute6_lists_url)
if res is not None and res.status_code == 200:
chnroute6_lists = res.text.strip().split('\n')
ipv6_list = [*chnroute6_lists]
ipv6_list = self._load_cn_ip_lists(family=IPv6Network)
if self._china_ip_route:
# Load Chnroute Lists
res = RequestUtils().get_res(url=chnroute_lists_url)
if res is not None and res.status_code == 200:
chnroute_lists = res.text.strip().split('\n')
ip_list = [*chnroute_lists]
ip_list = self._load_cn_ip_lists(family=IPv4Network)
for ip in ipv6_list + ip_list:
cidr_details.append(IpCidrItem(ip_cidr=str(ip), domain="CN", timestamp=int(time.time())))
do_sites = {site.domain: site.name for site in SiteOper().list_order_by_pri() if
site.id in self._bypassed_sites}
domain_name_map = {}
@@ -643,70 +855,74 @@ class ToBypassTrackers(_PluginBase):
for custom_tracker in self._custom_trackers.split('\n'):
if custom_tracker:
try:
socket.inet_pton(socket.AF_INET, custom_tracker)
if self._bypass_ipv4:
ip_list.append(f"{custom_tracker}/32")
except socket.error:
try:
socket.inet_pton(socket.AF_INET6, custom_tracker)
address = ipaddress.ip_address(custom_tracker)
net = ipaddress.ip_network(address)
if isinstance(net, IPv4Network):
if self._bypass_ipv4:
ip_list.append(net)
elif isinstance(net, IPv6Network):
if self._bypass_ipv6:
ipv6_list.append(ipaddress.ip_network(f"{custom_tracker}/128", strict=False).compressed)
except socket.error:
ipv6_list.append(net)
except ValueError:
domains.append(custom_tracker)
v6_ips = []
v4_ips = []
asyncio.run(resolve_all(domains, v6_ips, v4_ips))
ipv6_list.extend([ipaddress.ip_network(f"{ad}/128", strict=False).compressed for ad in v6_ips])
ip_list.extend([f"{ad}/32" for ad in v4_ips])
v6_nets = []
v4_nets = []
asyncio.run(resolve_all(domains, v6_nets, v4_nets, cidr_details))
ipv6_list.extend(v6_nets)
ip_list.extend(v4_nets)
for result in results:
if results[result]:
success_msg.append(f"{result}】 Trackers已被添加")
exempted_ip = []
exempted_ipv6 = []
exempted_ip: list[IPv4Network] = []
exempted_ipv6: list[IPv6Network] = []
exempted_domains = []
for exempted_domain in self._exempted_domains.split('\n'):
if exempted_domain:
try:
socket.inet_pton(socket.AF_INET, exempted_domain)
if self._bypass_ipv4:
exempted_ip.append(f"{exempted_domain}")
except socket.error:
try:
socket.inet_pton(socket.AF_INET6, exempted_domain)
address = ipaddress.ip_address(exempted_domain)
net = ipaddress.ip_network(address)
if isinstance(net, IPv4Network):
if self._bypass_ipv4:
exempted_ip.append(net)
elif isinstance(net, IPv6Network):
if self._bypass_ipv6:
exempted_ipv6.append(f"{exempted_domain}")
except socket.error:
exempted_ipv6.append(net)
exempted_cidr_details.append(IpCidrItem(ip_cidr=str(net), domain=exempted_domain,
timestamp=int(time.time())))
except ValueError:
exempted_domains.append(exempted_domain)
asyncio.run(resolve_all(exempted_domains, exempted_ip, exempted_ipv6))
asyncio.run(resolve_all(exempted_domains, exempted_ipv6, exempted_ip, exempted_cidr_details))
for ip in exempted_ip:
index = __search_ip(ip, ip_list)
index = ToBypassTrackers._search_subnet(ip, ip_list)
if index == -1:
continue
ip_larger = ip_list[index]
subnet = ip_list[index]
ip_list.pop(index)
length = int(ip_larger.split('/')[1])
if length < 12:
remaining_ip = __exclude_ip_range(ip_larger, f"{ip}/{length + 8}")
ip_list.extend(remaining_ip)
if subnet.prefixlen < 12:
new_subnet = IPv4Network((ip.network_address, subnet.prefixlen + 8), strict=False)
ip_list.extend(subnet.address_exclude(new_subnet))
for ip in exempted_ipv6:
index = __search_ip(ip, ipv6_list)
index = ToBypassTrackers._search_subnet(ip, ipv6_list)
if index == -1:
continue
ip_larger = ipv6_list[index]
subnet = ipv6_list[index]
ipv6_list.pop(index)
length = int(ip_larger.split('/')[1])
if length < 32:
remaining_ip = __exclude_ip_range(ip_larger, f"{ip}/{min(32, length + 8)}")
ipv6_list.extend(remaining_ip)
self.ipv4_txt = "\n".join(ip_list)
self.ipv6_txt = "\n".join(ipv6_list)
self.save_data("ipv4_txt", self.ipv4_txt)
self.save_data("ipv6_txt", self.ipv6_txt)
if subnet.prefixlen < 32:
new_subnet = IPv6Network((ip.network_address, min(32, subnet.prefixlen + 8)), strict=False)
ipv6_list.extend(subnet.address_exclude(new_subnet))
ipv4_txt = "\n".join(str(net) for net in ip_list)
ipv6_txt = "\n".join(str(net) for net in ipv6_list)
self.save_data("ipv4_txt", ipv4_txt)
self.save_data("ipv6_txt", ipv6_txt)
self.save_data("cidr_details", [detail.model_dump() for detail in cidr_details])
self.save_data("excluded_cidr_details", [detail.model_dump() for detail in exempted_cidr_details])
if self._notify:
res_message = success_msg + failed_msg
res_message = "\n".join(res_message)
self.post_message(title=f"【绕过Trackers】",
mtype=NotificationType.Plugin,
text=f"{res_message}"
)
self.post_message(
title=f"{self.plugin_name}",
mtype=NotificationType.Plugin,
text=f"{res_message}"
)

View File

@@ -1,86 +1,108 @@
import re
from typing import Optional, List, Callable
import ipaddress
import socket
from urllib.parse import urlparse
import dns.asyncresolver
import dns.resolver
from dns import asyncresolver, query
from dns.nameserver import Do53Nameserver, DoHNameserver, DoTNameserver, DoQNameserver
from dns.resolver import NoAnswer, NXDOMAIN
from app.log import logger
class DnsHelper:
def __init__(self, dns_server: str):
self.method_name = "Local"
self.doh_url = "https://dns.alidns.com/dns-query"
self.__resolver = dns.asyncresolver.Resolver()
self.__dns_query_method = self.__query_method(dns_server)
def __query_method(self, dns_input: str) -> Callable:
if not dns_input:
return self.query_dns_local
if dns_input.startswith('https://'):
self.doh_url = dns_input
self.method_name = dns_input
return self.query_dns_doh
udp_match = re.match(r"^(?:udp://)?(\[?.+?]?)(?::(\d+))?$", dns_input)
if udp_match:
try:
self.__resolver.nameservers = [udp_match.group(1).strip('[]')]
if udp_match.group(2):
self.__resolver.port = int(udp_match.group(2))
self.method_name = f"udp://{self.__resolver.nameservers[0]}:{self.__resolver.port}"
except Exception as e:
logger.warn(f'{e}, using default resolver')
return self.query_dns_local
return self.query_dns_udp
logger.warn(f'Unknown method {dns_input}, using default resolver')
return self.query_dns_local
def __init__(self, dns_server: str | None = None):
self._resolver = asyncresolver.Resolver()
self._use_tcp: bool = False
if dns_server:
self.nameserver = dns_server
async def query_dns(self, domain: str, dns_type: str = "A") -> Optional[List[str]]:
answers = await self.__dns_query_method(domain, dns_type)
return answers
@property
def nameserver(self) ->str:
nameserver = self._resolver.nameservers[0]
return str(nameserver)
async def query_dns_local(self, domain: str, dns_type: str = "A") -> Optional[List[str]]:
@nameserver.setter
def nameserver(self, value: str | None):
if value is None:
self._resolver = asyncresolver.Resolver()
return
self._parse_dns_server(value)
@staticmethod
def get_ip_from_hostname(hostname) -> str | None:
try:
answer = await self.__resolver.resolve(domain, dns_type)
return [record.address for record in answer if hasattr(record, "address")]
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return []
except Exception as e:
# logger.error(f"本地DNS查询错误: {e} {domain}")
# 获取IP地址
ip = socket.gethostbyname(hostname)
return ip
except socket.gaierror:
return None
async def query_dns_doh(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]:
@staticmethod
def is_ip_address(hostname):
try:
# 尝试解析为IP地址
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def _parse_dns_server(self, dns_server: str):
if "://" not in dns_server:
dns_server = f"udp://{dns_server}"
parsed = urlparse(dns_server)
# check and resolve the hostname
hostname = parsed.hostname
if hostname is None:
return
if DnsHelper.is_ip_address(hostname):
address = hostname
hostname = None
else:
address = DnsHelper.get_ip_from_hostname(hostname)
if address is None:
return
nameserver = None
match parsed.scheme:
case "udp":
nameserver = Do53Nameserver(address, parsed.port or 53)
case "tcp":
nameserver = Do53Nameserver(address, parsed.port or 53)
self._use_tcp = True
case "https":
nameserver = DoHNameserver(url=dns_server)
case "tls":
nameserver = DoTNameserver(address=address, port=parsed.port or 853, hostname=hostname)
case "h3":
nameserver = DoHNameserver(url=dns_server.replace("h3://", "https://"),
http_version=query.HTTPVersion.H3)
case "quic":
nameserver = DoQNameserver(address=address, port=parsed.port or 853, server_hostname=hostname)
case _:
nameserver = None
if nameserver is None:
self._resolver = asyncresolver.Resolver()
return
self._resolver.nameservers = [nameserver]
async def resolve_name(self, domain: str, family: int = socket.AF_UNSPEC) -> list[str] | None:
"""
使用 DNS-over-HTTPS (DoH) 异步解析域名
异步解析域名
:param domain: 要解析的域名
:param dns_type: DNS 记录类型,例如 'A', 'AAAA'
:param family: The address family
- socket.AF_UNSPEC: both IPv4 and IPv6 addresses
- socket.AF_INET6: IPv6 addresses only
- socket.AF_INET: IPv4 addresses only
:return: IP 地址列表,或 None
"""
try:
query = dns.message.make_query(domain, dns_type)
response = await dns.asyncquery.https(query, self.doh_url)
return [
item.address for rrset in response.answer for item in rrset.items
if hasattr(item, "address")
]
except Exception as e:
return None
async def query_dns_udp(self, domain: str, dns_type: str = 'A') -> Optional[List[str]]:
"""
使用 UDP 异步方式解析域名
:param domain: 域名
:param dns_type: 记录类型,如 A、AAAA
:return: IP地址列表 或 None
"""
try:
answer = await self.__resolver.resolve(domain, dns_type)
return [record.address for record in answer]
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
answer = await self._resolver.resolve_name(domain, family=family, tcp=self._use_tcp)
return [a for a in answer.addresses()]
except (NoAnswer, NXDOMAIN):
return []
except Exception:
except Exception as e:
logger.debug(f"DNS查询出错 ({domain}): {e} ")
return None

View File

@@ -1,2 +1,2 @@
dnspython~=2.7.0
aioquic~=1.2.0
dnspython~=2.8.0
aioquic~=1.2.0

View File

@@ -1 +1 @@
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIl0sICJoZHRpbWUub3JnIjogWyJoZHRpbWUub3JnIl0sICJoaXRwdC5jb20iOiBbImhpdHB0LmNvbSJdLCAiaHVkYnQuaHVzdC5lZHUuY24iOiBbImh1ZGJ0Lmh1c3QuZWR1LmNuIl0sICJpY2MyMDIyLmNvbSI6IFsidHJhY2tlci5pY2MyMDIyLnh5eiJdLCAiaWxvbGljb24uY29tIjogWyJ0cmFja2VyLmlsb2xpY29uLmNjIl0sICJrZWVwZnJkcy5jb20iOiBbInRyYWNrZXIua2VlcGZyZHMuY29tIl0sICJtLXRlYW0uY2MiOiBbInRyYWNrZXIubS10ZWFtLmNjIiwgInRyYWNrZXIubS10ZWFtLmlvIl0sICJtb25pa2FkZXNpZ24udWsiOiBbInRyYWNrZXIubW9uaWthZGVzaWduLnVrIiwgImRhaWtpcmFpLm1vbmlrYWRlc2lnbi51ayIsICJhbmltZS1uby1pbmRleC5jb20iXSwgIm5pY2VwdC5uZXQiOiBbInd3dy5uaWNlcHQubmV0Il0sICJva3B0Lm5ldCI6IFsid3d3Lm9rcHQubmV0Il0sICJwdGhvbWUubmV0IjogWyJwdGhvbWUubmV0Il0sICJwdGxncy5vcmciOiBbInB0bC5ncyIsICJyZWxheTAxLnB0bC5ncyJdLCAicHRzYmFvLmNsdWIiOiBbInB0c2Jhby5jbHViIl0sICJwdHRpbWUub3JnIjogWyJ3d3cucHR0aW1lLm9yZyJdLCAicHR6b25lLnh5eiI6IFsicHR6b25lLnh5eiJdLCAicWluZ3dhcHQuY29tIjogWyJ0cmFja2VyLnFpbmd3YS5wcm8iLCAidHJhY2tlci5xaW5nd2FwdC5jb20iXSwgInJhaW5nZmgudG9wIjogWyJyYWluZ2ZoLnRvcCJdLCAicm91c2kuemlwIjogWyJoaXRwdC5jb20iXSwgInNwcmluZ3N1bmRheS5uZXQiOiBbIm9uNi5zcHJpbmdzdW5kYXkubmV0IiwgIm9uLnNwcmluZ3N1bmRheS5uZXQiXSwgInRqdXB0Lm9yZyI6IFsidHJhY2tlci1wdWJsaWMudGp1cHQub3JnIl0sICJ0b3RoZWdsb3J5LmltIjogWyJ0cmFja2VyLnRvdGhlZ2xvcnkuaW0iXSwgInUyLmRtaHkub3JnIjogWyJkYXlkcmVhbS5kbWh5LmJlc3QiXSwgInhpbmd5dW5nZS50b3AiOiBbInRyYWNrZXIueGluZ3l1bmdlLnRvcCIsICJ0cmFja2VyLnhpbmd5dW5nZS5zYnMiXSwgInptcHQuY2MiOiBbInptcHQuY2MiXSwgImhoYW5jbHViLnRvcCI6IFsidHJhY2tlci5oaGFuY2x1Yi50b3AiXSwgImhkY2l0eS5jaXR5IjogWyJzeW5jLmxlbml0ZXIub3JnIl19
eyI1MnB0LnNpdGUiOiBbIjUycHQuc2l0ZSJdLCAiYXVkaWVuY2VzLm1lIjogWyJ0LmF1ZGllbmNlcy5tZSIsICJ0cmFja2VyLmNpbmVmaWxlcy5pbmZvIl0sICJidHNjaG9vbC5jbHViIjogWyJwdC5idHNjaG9vbC5jbHViIl0sICJieXIucHQiOiBbInRyYWNrZXIuYnlyLnB0Il0sICJjYXJwdC5uZXQiOiBbInRyYWNrZXIuY2FycHQubmV0Il0sICJjcmFicHQudmlwIjogWyJjcmFicHQudmlwIl0sICJjc3B0LnRvcCI6IFsidHJhY2tlci5jc3B0LnRvcCIsICJ0cmFja2VyLmNzcHQuY2MiLCAidHJhY2tlci5jc3B0LmRhdGUiXSwgImRpc2NmYW4ubmV0IjogWyJkaXNjZmFuLnh5eiJdLCAiZWFzdGdhbWUub3JnIjogWyJwdC5lYXN0Z2FtZS5vcmciXSwgImV0OC5vcmciOiBbImV0OC5vcmciLCAidC5ldDgub3JnIl0sICJnYW1lZ2FtZXB0LmNvbSI6IFsid3d3LmdhbWVnYW1lcHQuY29tIl0sICJoZGFyZWEuY2x1YiI6IFsidHJhY2tlci5oZGFyZWEuY2x1YiJdLCAiaGRkb2xieS5jb20iOiBbInQuaGRkb2xieS5jb20iXSwgImhkZmFucy5vcmciOiBbImhkZmFucy5vcmciXSwgImhka3lsLmluIjogWyJ0cmFja2VyLmhka3lsLmluIiwgInd3dy5oZGt5bGluLnRvcCJdLCAiaGR0aW1lLm9yZyI6IFsiaGR0aW1lLm9yZyJdLCAiaGl0cHQuY29tIjogWyJoaXRwdC5jb20iXSwgImh1ZGJ0Lmh1c3QuZWR1LmNuIjogWyJodWRidC5odXN0LmVkdS5jbiJdLCAiaWNjMjAyMi5jb20iOiBbInRyYWNrZXIuaWNjMjAyMi54eXoiXSwgImlsb2xpY29uLmNvbSI6IFsidHJhY2tlci5pbG9saWNvbi5jYyJdLCAia2VlcGZyZHMuY29tIjogWyJ0cmFja2VyLmtlZXBmcmRzLmNvbSJdLCAibS10ZWFtLmNjIjogWyJ0cmFja2VyLm0tdGVhbS5jYyIsICJ0cmFja2VyLm0tdGVhbS5pbyJdLCAibW9uaWthZGVzaWduLnVrIjogWyJ0cmFja2VyLm1vbmlrYWRlc2lnbi51ayIsICJkYWlraXJhaS5tb25pa2FkZXNpZ24udWsiLCAiYW5pbWUtbm8taW5kZXguY29tIl0sICJuaWNlcHQubmV0IjogWyJ3d3cubmljZXB0Lm5ldCJdLCAib2twdC5uZXQiOiBbInd3dy5va3B0Lm5ldCJdLCAicHRob21lLm5ldCI6IFsicHRob21lLm5ldCJdLCAicHRsZ3Mub3JnIjogWyJwdGwuZ3MiLCAicmVsYXkwMS5wdGwuZ3MiXSwgInB0c2Jhby5jbHViIjogWyJwdHNiYW8uY2x1YiJdLCAicHR0aW1lLm9yZyI6IFsid3d3LnB0dGltZS5vcmciXSwgInB0em9uZS54eXoiOiBbInB0em9uZS54eXoiXSwgInFpbmd3YXB0LmNvbSI6IFsidHJhY2tlci5xaW5nd2EucHJvIiwgInRyYWNrZXIucWluZ3dhcHQuY29tIiwgInRyYWNrZXIucWluZ3dhcHQub3JnIl0sICJyYWluZ2ZoLnRvcCI6IFsicmFpbmdmaC50b3AiXSwgInJvdXNpLnppcCI6IFsiaGl0cHQuY29tIl0sICJzcHJpbmdzdW5kYXkubmV0IjogWyJvbjYuc3ByaW5nc3VuZGF5Lm5ldCIsICJvbi5zcHJpbmdzdW5kYXkubmV0Il0sICJ0anVwdC5vcmciOiBbInRyYWNrZXItcHVibGljLnRqdXB0Lm9yZyJdLCAidG90aGVnbG9yeS5pbSI6IFsidHJhY2tlci50b3RoZWdsb3J5LmltIl0sICJ1Mi5kbWh5Lm9yZyI6IFsiZGF5ZHJlYW0uZG1oeS5iZXN0Il0sICJ4aW5neXVuZ2UudG9wIjogWyJ0cmFja2VyLnhpbmd5dW5nZS50b3AiLCAidHJhY2tlci54aW5neXVuZ2Uuc2JzIl0sICJ6bXB0LmNjIjogWyJ6bXB0LmNjIl0sICJoaGFuY2x1Yi50b3AiOiBbInRyYWNrZXIuaGhhbmNsdWIudG9wIl0sICJoZGNpdHkuY2l0eSI6IFsic3luYy5sZW5pdGVyLm9yZyJdfQ==