ddns 4.0.2__py2.py3-none-any.whl → 4.1.0b2__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ddns/__builtins__.pyi +5 -0
- ddns/__init__.py +2 -9
- ddns/__main__.py +77 -146
- ddns/cache.py +183 -0
- ddns/{util/ip.py → ip.py} +26 -21
- ddns/provider/__init__.py +79 -0
- ddns/provider/_base.py +539 -0
- ddns/provider/_signature.py +113 -0
- ddns/provider/alidns.py +139 -171
- ddns/provider/aliesa.py +129 -0
- ddns/provider/callback.py +66 -105
- ddns/provider/cloudflare.py +87 -151
- ddns/provider/debug.py +20 -0
- ddns/provider/dnscom.py +91 -169
- ddns/provider/dnspod.py +104 -174
- ddns/provider/dnspod_com.py +11 -8
- ddns/provider/edgeone.py +82 -0
- ddns/provider/he.py +41 -78
- ddns/provider/huaweidns.py +133 -256
- ddns/provider/namesilo.py +159 -0
- ddns/provider/noip.py +103 -0
- ddns/provider/tencentcloud.py +195 -0
- ddns/util/comment.py +88 -0
- ddns/util/http.py +228 -0
- {ddns-4.0.2.dist-info → ddns-4.1.0b2.dist-info}/METADATA +109 -75
- ddns-4.1.0b2.dist-info/RECORD +31 -0
- ddns/util/cache.py +0 -139
- ddns/util/config.py +0 -317
- ddns-4.0.2.dist-info/RECORD +0 -21
- {ddns-4.0.2.dist-info → ddns-4.1.0b2.dist-info}/WHEEL +0 -0
- {ddns-4.0.2.dist-info → ddns-4.1.0b2.dist-info}/entry_points.txt +0 -0
- {ddns-4.0.2.dist-info → ddns-4.1.0b2.dist-info}/licenses/LICENSE +0 -0
- {ddns-4.0.2.dist-info → ddns-4.1.0b2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
Tencent Cloud DNSPod API
|
|
4
|
+
腾讯云 DNSPod API
|
|
5
|
+
|
|
6
|
+
@author: NewFuture
|
|
7
|
+
"""
|
|
8
|
+
from ._base import BaseProvider, TYPE_JSON
|
|
9
|
+
from ._signature import hmac_sha256_authorization, sha256_hash, hmac_sha256
|
|
10
|
+
from time import time, strftime, gmtime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TencentCloudProvider(BaseProvider):
|
|
14
|
+
"""
|
|
15
|
+
腾讯云 DNSPod API 提供商
|
|
16
|
+
Tencent Cloud DNSPod API Provider
|
|
17
|
+
|
|
18
|
+
API Version: 2021-03-23
|
|
19
|
+
Documentation: https://cloud.tencent.com/document/api/1427
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
endpoint = "https://dnspod.tencentcloudapi.com"
|
|
23
|
+
content_type = TYPE_JSON
|
|
24
|
+
|
|
25
|
+
# 腾讯云 DNSPod API 配置
|
|
26
|
+
service = "dnspod"
|
|
27
|
+
version_date = "2021-03-23"
|
|
28
|
+
|
|
29
|
+
def _request(self, action, **params):
|
|
30
|
+
# type: (str, **(str | int | bytes | bool | None)) -> dict | None
|
|
31
|
+
"""
|
|
32
|
+
发送腾讯云 API 请求
|
|
33
|
+
|
|
34
|
+
API 文档: https://cloud.tencent.com/document/api/1427/56187
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
action (str): API 操作名称
|
|
38
|
+
params (dict): 请求参数
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
dict: API 响应结果
|
|
42
|
+
"""
|
|
43
|
+
# 构建请求体
|
|
44
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
45
|
+
body = self._encode_body(params)
|
|
46
|
+
|
|
47
|
+
# 构建请求头,小写 腾讯云只签名特定头部
|
|
48
|
+
headers = {
|
|
49
|
+
"content-type": self.content_type,
|
|
50
|
+
"host": self.endpoint.split("://", 1)[1].strip("/"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 腾讯云特殊的密钥派生过程
|
|
54
|
+
date = strftime("%Y-%m-%d", gmtime())
|
|
55
|
+
credential_scope = "{}/{}/tc3_request".format(date, self.service)
|
|
56
|
+
|
|
57
|
+
# 派生签名密钥
|
|
58
|
+
secret_date = hmac_sha256("TC3" + self.token, date).digest()
|
|
59
|
+
secret_service = hmac_sha256(secret_date, self.service).digest()
|
|
60
|
+
signing_key = hmac_sha256(secret_service, "tc3_request").digest()
|
|
61
|
+
|
|
62
|
+
# 预处理模板字符串
|
|
63
|
+
auth_format = "TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders={SignedHeaders}, Signature={Signature}" % (
|
|
64
|
+
self.id,
|
|
65
|
+
credential_scope,
|
|
66
|
+
)
|
|
67
|
+
timestamp = str(int(time()))
|
|
68
|
+
sign_template = "\n".join(["TC3-HMAC-SHA256", timestamp, credential_scope, "{HashedCanonicalRequest}"])
|
|
69
|
+
authorization = hmac_sha256_authorization(
|
|
70
|
+
secret_key=signing_key,
|
|
71
|
+
method="POST",
|
|
72
|
+
path="/",
|
|
73
|
+
query="",
|
|
74
|
+
headers=headers,
|
|
75
|
+
body_hash=sha256_hash(body),
|
|
76
|
+
signing_string_format=sign_template,
|
|
77
|
+
authorization_format=auth_format,
|
|
78
|
+
)
|
|
79
|
+
# X-TC 更新签名之后方可添加
|
|
80
|
+
headers.update(
|
|
81
|
+
{
|
|
82
|
+
"X-TC-Action": action,
|
|
83
|
+
"X-TC-Version": self.version_date,
|
|
84
|
+
"X-TC-Timestamp": timestamp,
|
|
85
|
+
"authorization": authorization,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response = self._http("POST", "/", body=body, headers=headers)
|
|
90
|
+
if response and "Response" in response:
|
|
91
|
+
if "Error" in response["Response"]:
|
|
92
|
+
error = response["Response"]["Error"]
|
|
93
|
+
self.logger.error(
|
|
94
|
+
"TencentCloud API error: %s - %s",
|
|
95
|
+
error.get("Code", "Unknown"),
|
|
96
|
+
error.get("Message", "Unknown error"),
|
|
97
|
+
)
|
|
98
|
+
return None
|
|
99
|
+
return response["Response"]
|
|
100
|
+
|
|
101
|
+
self.logger.warning("Unexpected response format: %s", response)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def _query_zone_id(self, domain):
|
|
105
|
+
# type: (str) -> str | None
|
|
106
|
+
"""查询域名的 zone_id (domain id) https://cloud.tencent.com/document/api/1427/56173"""
|
|
107
|
+
# 使用 DescribeDomain API 查询指定域名的信息
|
|
108
|
+
response = self._request("DescribeDomain", Domain=domain)
|
|
109
|
+
|
|
110
|
+
if not response or "DomainInfo" not in response:
|
|
111
|
+
self.logger.debug("Domain info not found or query failed for: %s", domain)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
domain_id = response.get("DomainInfo", {}).get("DomainId")
|
|
115
|
+
|
|
116
|
+
if domain_id is not None:
|
|
117
|
+
self.logger.debug("Found domain %s with ID: %s", domain, domain_id)
|
|
118
|
+
return str(domain_id)
|
|
119
|
+
|
|
120
|
+
self.logger.debug("Domain ID not found in response for: %s", domain)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
|
|
124
|
+
# type: (str, str, str, str, str | None, dict) -> dict | None
|
|
125
|
+
"""查询 DNS 记录列表 https://cloud.tencent.com/document/api/1427/56166"""
|
|
126
|
+
|
|
127
|
+
response = self._request(
|
|
128
|
+
"DescribeRecordList",
|
|
129
|
+
DomainId=int(zone_id),
|
|
130
|
+
Subdomain=subdomain,
|
|
131
|
+
Domain=main_domain,
|
|
132
|
+
RecordType=record_type,
|
|
133
|
+
RecordLine=line,
|
|
134
|
+
**extra
|
|
135
|
+
)
|
|
136
|
+
if not response or "RecordList" not in response:
|
|
137
|
+
self.logger.debug("No records found or query failed")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
records = response["RecordList"]
|
|
141
|
+
if not records:
|
|
142
|
+
self.logger.debug("No records found for subdomain: %s", subdomain)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# 查找匹配的记录
|
|
146
|
+
target_name = subdomain if subdomain and subdomain != "@" else "@"
|
|
147
|
+
for record in records:
|
|
148
|
+
if record.get("Name") == target_name and record.get("Type") == record_type.upper():
|
|
149
|
+
self.logger.debug("Found existing record: %s", record)
|
|
150
|
+
return record
|
|
151
|
+
|
|
152
|
+
self.logger.debug("No matching record found")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
|
|
156
|
+
"""创建 DNS 记录 https://cloud.tencent.com/document/api/1427/56180"""
|
|
157
|
+
extra["Remark"] = extra.get("Remark", self.remark)
|
|
158
|
+
response = self._request(
|
|
159
|
+
"CreateRecord",
|
|
160
|
+
Domain=main_domain,
|
|
161
|
+
DomainId=int(zone_id),
|
|
162
|
+
SubDomain=subdomain,
|
|
163
|
+
RecordType=record_type,
|
|
164
|
+
Value=value,
|
|
165
|
+
RecordLine=line or "默认",
|
|
166
|
+
TTL=int(ttl) if ttl else None,
|
|
167
|
+
**extra
|
|
168
|
+
)
|
|
169
|
+
if response and "RecordId" in response:
|
|
170
|
+
self.logger.info("Record created successfully with ID: %s", response["RecordId"])
|
|
171
|
+
return True
|
|
172
|
+
self.logger.error("Failed to create record:\n%s", response)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
|
|
176
|
+
"""更新 DNS 记录: https://cloud.tencent.com/document/api/1427/56157"""
|
|
177
|
+
extra["Remark"] = extra.get("Remark", self.remark)
|
|
178
|
+
response = self._request(
|
|
179
|
+
"ModifyRecord",
|
|
180
|
+
Domain=old_record.get("Domain", ""),
|
|
181
|
+
DomainId=old_record.get("DomainId", int(zone_id)),
|
|
182
|
+
SubDomain=old_record.get("Name"),
|
|
183
|
+
RecordId=old_record.get("RecordId"),
|
|
184
|
+
RecordType=record_type,
|
|
185
|
+
RecordLine=old_record.get("Line", line or "默认"),
|
|
186
|
+
Value=value,
|
|
187
|
+
TTL=int(ttl) if ttl else None,
|
|
188
|
+
**extra
|
|
189
|
+
)
|
|
190
|
+
if response and "RecordId" in response:
|
|
191
|
+
self.logger.info("Record updated successfully")
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
self.logger.error("Failed to update record")
|
|
195
|
+
return False
|
ddns/util/comment.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Comment removal utility for JSON configuration files.
|
|
4
|
+
Supports both # and // style single line comments.
|
|
5
|
+
@author: GitHub Copilot
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def remove_comment(content):
|
|
10
|
+
# type: (str) -> str
|
|
11
|
+
"""
|
|
12
|
+
移除字符串中的单行注释。
|
|
13
|
+
支持 # 和 // 两种注释风格。
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
content (str): 包含注释的字符串内容
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
str: 移除注释后的字符串
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> remove_comment('{"key": "value"} // comment')
|
|
23
|
+
'{"key": "value"} '
|
|
24
|
+
>>> remove_comment('# This is a comment\\n{"key": "value"}')
|
|
25
|
+
'\\n{"key": "value"}'
|
|
26
|
+
"""
|
|
27
|
+
if not content:
|
|
28
|
+
return content
|
|
29
|
+
|
|
30
|
+
lines = content.splitlines()
|
|
31
|
+
cleaned_lines = []
|
|
32
|
+
|
|
33
|
+
for line in lines:
|
|
34
|
+
# 移除行内注释,但要小心不要破坏字符串内的内容
|
|
35
|
+
cleaned_line = _remove_line_comment(line)
|
|
36
|
+
cleaned_lines.append(cleaned_line)
|
|
37
|
+
|
|
38
|
+
return "\n".join(cleaned_lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _remove_line_comment(line):
|
|
42
|
+
# type: (str) -> str
|
|
43
|
+
"""
|
|
44
|
+
移除单行中的注释部分。
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
line (str): 要处理的行
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
str: 移除注释后的行
|
|
51
|
+
"""
|
|
52
|
+
# 检查是否是整行注释
|
|
53
|
+
stripped = line.lstrip()
|
|
54
|
+
if stripped.startswith("#") or stripped.startswith("//"):
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
# 查找行内注释,需要考虑字符串内容
|
|
58
|
+
in_string = False
|
|
59
|
+
quote_char = None
|
|
60
|
+
i = 0
|
|
61
|
+
|
|
62
|
+
while i < len(line):
|
|
63
|
+
char = line[i]
|
|
64
|
+
|
|
65
|
+
# 处理字符串内的转义序列
|
|
66
|
+
if in_string and char == "\\" and i + 1 < len(line):
|
|
67
|
+
i += 2 # 跳过转义字符
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# 处理引号字符
|
|
71
|
+
if char in ('"', "'"):
|
|
72
|
+
if not in_string:
|
|
73
|
+
in_string = True
|
|
74
|
+
quote_char = char
|
|
75
|
+
elif char == quote_char:
|
|
76
|
+
in_string = False
|
|
77
|
+
quote_char = None
|
|
78
|
+
|
|
79
|
+
# 在字符串外检查注释标记
|
|
80
|
+
elif not in_string:
|
|
81
|
+
if char == "#":
|
|
82
|
+
return line[:i].rstrip()
|
|
83
|
+
elif char == "/" and i + 1 < len(line) and line[i + 1] == "/":
|
|
84
|
+
return line[:i].rstrip()
|
|
85
|
+
|
|
86
|
+
i += 1
|
|
87
|
+
|
|
88
|
+
return line
|
ddns/util/http.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
HTTP请求工具模块
|
|
4
|
+
|
|
5
|
+
HTTP utilities module for DDNS project.
|
|
6
|
+
Provides common HTTP functionality including redirect following support.
|
|
7
|
+
|
|
8
|
+
@author: NewFuture
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from logging import getLogger
|
|
12
|
+
import ssl
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
try: # python 3
|
|
16
|
+
from urllib.request import Request, HTTPSHandler, ProxyHandler, build_opener, OpenerDirector # noqa: F401
|
|
17
|
+
from urllib.parse import quote, urlencode
|
|
18
|
+
from urllib.error import HTTPError, URLError
|
|
19
|
+
except ImportError: # python 2
|
|
20
|
+
from urllib2 import Request, HTTPSHandler, ProxyHandler, build_opener, HTTPError, URLError # type: ignore[no-redef]
|
|
21
|
+
from urllib import urlencode, quote # type: ignore[no-redef]
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"send_http_request",
|
|
25
|
+
"HttpResponse",
|
|
26
|
+
"quote",
|
|
27
|
+
"urlencode",
|
|
28
|
+
"URLError",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
logger = getLogger().getChild(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpResponse(object):
|
|
35
|
+
"""HTTP响应封装类"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, status, reason, headers, body):
|
|
38
|
+
# type: (int, str, object, str) -> None
|
|
39
|
+
"""
|
|
40
|
+
初始化HTTP响应对象
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
status (int): HTTP状态码
|
|
44
|
+
reason (str): 状态原因短语
|
|
45
|
+
headers (object): 响应头对象,直接使用 response.info()
|
|
46
|
+
body (str): 响应体内容
|
|
47
|
+
"""
|
|
48
|
+
self.status = status
|
|
49
|
+
self.reason = reason
|
|
50
|
+
self.headers = headers
|
|
51
|
+
self.body = body
|
|
52
|
+
|
|
53
|
+
def get_header(self, name, default=None):
|
|
54
|
+
# type: (str, str | None) -> str | None
|
|
55
|
+
"""
|
|
56
|
+
获取指定名称的头部值
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name (str): 头部名称
|
|
60
|
+
default (str | None): 默认值
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
str | None: 头部值,如果不存在则返回默认值
|
|
64
|
+
"""
|
|
65
|
+
return self.headers.get(name, default) # type: ignore[union-attr]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# 移除了自定义重定向处理器,使用urllib2/urllib.request的内置重定向处理
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _create_ssl_context(verify_ssl):
|
|
72
|
+
# type: (bool | str) -> ssl.SSLContext | None
|
|
73
|
+
"""创建SSL上下文"""
|
|
74
|
+
ssl_context = ssl.create_default_context()
|
|
75
|
+
|
|
76
|
+
if verify_ssl is False:
|
|
77
|
+
# 禁用SSL验证
|
|
78
|
+
ssl_context.check_hostname = False
|
|
79
|
+
ssl_context.verify_mode = ssl.CERT_NONE
|
|
80
|
+
elif hasattr(verify_ssl, "lower") and verify_ssl.lower() not in ("auto", "true"): # type: ignore[union-attr]
|
|
81
|
+
# 使用自定义CA证书
|
|
82
|
+
try:
|
|
83
|
+
ssl_context.load_verify_locations(verify_ssl) # type: ignore[arg-type]
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error("Failed to load CA certificate from %s: %s", verify_ssl, e)
|
|
86
|
+
return None
|
|
87
|
+
else:
|
|
88
|
+
# 默认验证,尝试加载系统证书
|
|
89
|
+
_load_system_ca_certs(ssl_context)
|
|
90
|
+
|
|
91
|
+
return ssl_context
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_system_ca_certs(ssl_context):
|
|
95
|
+
# type: (ssl.SSLContext) -> None
|
|
96
|
+
"""加载系统CA证书"""
|
|
97
|
+
# 常见CA证书路径
|
|
98
|
+
ca_paths = [
|
|
99
|
+
# Linux/Unix常用路径
|
|
100
|
+
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
|
101
|
+
"/etc/pki/tls/certs/ca-bundle.crt", # RedHat/CentOS
|
|
102
|
+
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
|
103
|
+
"/etc/ssl/cert.pem", # OpenBSD
|
|
104
|
+
"/usr/local/share/certs/ca-root-nss.crt", # FreeBSD
|
|
105
|
+
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # Fedora/RHEL
|
|
106
|
+
# macOS路径
|
|
107
|
+
"/usr/local/etc/openssl/cert.pem", # macOS with Homebrew
|
|
108
|
+
"/opt/local/etc/openssl/cert.pem", # macOS with MacPorts
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Windows额外路径
|
|
112
|
+
if os.name == "nt":
|
|
113
|
+
ca_paths.append("C:\\Program Files\\Git\\mingw64\\ssl\\cert.pem")
|
|
114
|
+
ca_paths.append("C:\\Program Files\\OpenSSL\\ssl\\cert.pem")
|
|
115
|
+
|
|
116
|
+
loaded_count = 0
|
|
117
|
+
for ca_path in ca_paths:
|
|
118
|
+
if os.path.isfile(ca_path):
|
|
119
|
+
try:
|
|
120
|
+
ssl_context.load_verify_locations(ca_path)
|
|
121
|
+
loaded_count += 1
|
|
122
|
+
logger.debug("Loaded CA certificates from: %s", ca_path)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.info("Failed to load CA certificates from %s: %s", ca_path, e)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _create_opener(proxy, verify_ssl):
|
|
128
|
+
# type: (str | None, bool | str) -> OpenerDirector
|
|
129
|
+
"""创建URL打开器,支持代理和SSL配置"""
|
|
130
|
+
handlers = []
|
|
131
|
+
|
|
132
|
+
if proxy:
|
|
133
|
+
handlers.append(ProxyHandler({"http": proxy, "https": proxy}))
|
|
134
|
+
|
|
135
|
+
ssl_context = _create_ssl_context(verify_ssl)
|
|
136
|
+
if ssl_context:
|
|
137
|
+
handlers.append(HTTPSHandler(context=ssl_context))
|
|
138
|
+
|
|
139
|
+
return build_opener(*handlers)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def send_http_request(method, url, body=None, headers=None, proxy=None, verify_ssl=True):
|
|
143
|
+
# type: (str, str, str | bytes | None, dict[str, str] | None, str | None, bool | str) -> HttpResponse
|
|
144
|
+
"""
|
|
145
|
+
发送HTTP/HTTPS请求,支持重定向跟随和灵活的SSL验证
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
method (str): HTTP方法,如GET、POST等
|
|
149
|
+
url (str): 请求的URL
|
|
150
|
+
body (str | bytes | None): 请求体
|
|
151
|
+
headers (dict[str, str] | None): 请求头
|
|
152
|
+
proxy (str | None): 代理地址
|
|
153
|
+
verify_ssl (bool | str): SSL验证配置
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
HttpResponse: 响应对象
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
URLError: 如果请求失败
|
|
160
|
+
ssl.SSLError: 如果SSL验证失败
|
|
161
|
+
"""
|
|
162
|
+
# 准备请求
|
|
163
|
+
if isinstance(body, str):
|
|
164
|
+
body = body.encode("utf-8")
|
|
165
|
+
|
|
166
|
+
req = Request(url, data=body)
|
|
167
|
+
req.get_method = lambda: method # type: ignore[attr-defined]
|
|
168
|
+
|
|
169
|
+
if headers:
|
|
170
|
+
for key, value in headers.items():
|
|
171
|
+
req.add_header(key, value)
|
|
172
|
+
|
|
173
|
+
# 创建opener并发送请求
|
|
174
|
+
opener = _create_opener(proxy, verify_ssl)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
response = opener.open(req)
|
|
178
|
+
response_headers = response.info()
|
|
179
|
+
raw_body = response.read()
|
|
180
|
+
decoded_body = _decode_response_body(raw_body, response_headers.get("Content-Type"))
|
|
181
|
+
return HttpResponse(response.getcode(), getattr(response, "msg", ""), response_headers, decoded_body)
|
|
182
|
+
except HTTPError as e:
|
|
183
|
+
# 记录HTTP错误并读取响应体用于调试
|
|
184
|
+
response_headers = getattr(e, "headers", {})
|
|
185
|
+
raw_body = e.read()
|
|
186
|
+
decoded_body = _decode_response_body(raw_body, response_headers.get("Content-Type"))
|
|
187
|
+
logger.error("HTTP error %s: %s for %s", e.code, getattr(e, "reason", str(e)), url)
|
|
188
|
+
return HttpResponse(e.code, getattr(e, "reason", str(e)), response_headers, decoded_body)
|
|
189
|
+
except ssl.SSLError:
|
|
190
|
+
if verify_ssl == "auto":
|
|
191
|
+
logger.warning("SSL verification failed, switching to unverified connection %s", url)
|
|
192
|
+
return send_http_request(method, url, body, headers, proxy, False)
|
|
193
|
+
else:
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _decode_response_body(raw_body, content_type):
|
|
198
|
+
# type: (bytes, str | None) -> str
|
|
199
|
+
"""解码HTTP响应体,优先使用UTF-8"""
|
|
200
|
+
if not raw_body:
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
# 从Content-Type提取charset
|
|
204
|
+
charsets = ["utf-8", "gbk", "ascii", "latin-1"]
|
|
205
|
+
if content_type and "charset=" in content_type.lower():
|
|
206
|
+
start = content_type.lower().find("charset=") + 8
|
|
207
|
+
end = content_type.find(";", start)
|
|
208
|
+
if end == -1:
|
|
209
|
+
end = len(content_type)
|
|
210
|
+
charset = content_type[start:end].strip("'\" ").lower()
|
|
211
|
+
charsets.insert(0, charset)
|
|
212
|
+
# 处理常见别名
|
|
213
|
+
if charset == "gb2312":
|
|
214
|
+
charsets.remove("gbk")
|
|
215
|
+
charsets.insert(0, "gbk")
|
|
216
|
+
elif charset == "iso-8859-1":
|
|
217
|
+
charsets.remove("latin-1")
|
|
218
|
+
charsets.insert(0, "latin-1")
|
|
219
|
+
|
|
220
|
+
# 按优先级尝试解码
|
|
221
|
+
for encoding in charsets:
|
|
222
|
+
try:
|
|
223
|
+
return raw_body.decode(encoding)
|
|
224
|
+
except (UnicodeDecodeError, LookupError):
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# 最终后备:UTF-8替换错误字符
|
|
228
|
+
return raw_body.decode("utf-8", errors="replace")
|