函数使用(七)

前言

  • 这一篇收四个「写脚本时经常要查」的标准库能力:eval / ast.literal_eval(把字符串当代码或当数据)、hashlib(摘要与口令散列)、urllib.parse(URL 拆开与拼装)、http.client(最底层的 HTTP 请求)。
  • 所有示例都在 Python 3.13.9 / Windows 上实跑过,输出原样贴在代码块下面。会联网的示例只打印状态码、布尔结论这类稳定信息,不打印字节数,免得你重跑对不上;断网时会打印「跳过」。
  • 这些内容原来记在《短信邮件》里,和发邮件、发短信混在一篇,就挪到这里独立成篇;那篇只留邮件与短信。

eval 与 ast.literal_eval

  • 基本语法

    1
    eval(expression, globals=None, locals=None)     # expression 是字符串形式的「表达式」
    • globals / locals 都是字典,用来指定这段字符串能在什么命名空间里跑;不传就用当前作用域。
    • 只接受表达式,赋值、if、for 这些语句会抛 SyntaxError。
  • 能做什么

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    x = 1
    print(eval('x + 1')) # 能读当前作用域的变量
    print(eval('2 + 3 * 4')) # 就是个表达式求值
    print(eval('x + 1') == 2) # 返回的是求值结果


    def square(n):
    return n * n


    print(eval('square(5)')) # 也能调用作用域里的函数

    输出:

    1
    2
    3
    4
    2
    14
    True
    25
  • 不能做什么,以及和 exec 的区别

    1
    2
    3
    4
    5
    6
    7
    try:
    eval('x = 1') # 赋值是语句,不是表达式
    except SyntaxError as e:
    print('eval 里赋值:', type(e).__name__)

    ret = exec('y = 2') # exec 执行语句块,永远返回 None
    print('exec 的返回值:', ret, '| 但它真的创建了变量:', y)

    输出:

    1
    2
    eval 里赋值: SyntaxError
    exec 的返回值: None | 但它真的创建了变量: 2

    一句话:要结果用 eval,要执行用 exec;两个都别拿去跑不可信的输入。

  • 安全性:它能跑任意代码

    1
    2
    3
    4
    import os

    print('能读到目录:', bool(eval('os.getcwd()'))) # 当前作用域里有 os,就能为所欲为
    print('路径分隔符:', eval('__import__("os").sep')) # 没 import 也能自己现取

    输出:

    1
    2
    能读到目录: True
    路径分隔符: \

    所以 eval(input()) 这种写法等于把解释器交给用户。真要限制,就把可用名字交给它:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from math import sqrt, sin, cos

    safe = {'__builtins__': None} # 关掉内置命名空间
    safe.update({k: v for k, v in globals().items() if k in ['sqrt', 'sin', 'cos']})

    print(eval('sqrt(16)', safe, safe)) # 白名单里的能用
    try:
    eval('__import__("os").getcwd()', safe, safe) # 想 import 就别想了
    except TypeError as e:
    print('受限环境里 import:', e)

    输出:

    1
    2
    4.0
    受限环境里 import: 'NoneType' object is not subscriptable

    注意三点:__builtins__ 设成 None 只是关掉内置函数,挡不住你漏放进白名单的对象上的方法;另外只要传了字典,Python 会自动往里补一个 __builtins__,所以别以为写个 {'sqrt': sqrt} 就安全了。最后,__builtins__ 建议给 {} 而不是 None——给 None 时沙箱里任何找不到的名字都会抛 TypeError: 'NoneType' object is not subscriptable,而不是 NameError(实测)。

  • 更安全的替代:ast.literal_eval

    只解析字面量(数字、字符串、列表、元组、字典、布尔、None)和它们的组合,遇到函数调用或运算直接报错,适合「配置里存了个字符串形式的列表」这类场景。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import ast

    print(ast.literal_eval('[1, 2, 3]'))
    print(ast.literal_eval("{'a': 1, 'b': (2, 3)}"))
    try:
    ast.literal_eval('1 + 2') # 算式不行
    except ValueError as e:
    print('literal_eval 算式:', type(e).__name__)
    try:
    ast.literal_eval('square(5)') # 函数调用也不行
    except ValueError:
    print('literal_eval 函数调用: ValueError')

    输出:

    1
    2
    3
    4
    [1, 2, 3]
    {'a': 1, 'b': (2, 3)}
    literal_eval 算式: ValueError
    literal_eval 函数调用: ValueError
  • 整体示例(eval / literal_eval)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    import ast
    from math import sqrt

    # 1) 表达式求值:能读作用域、能调函数
    base, rate = 1000, 0.05
    print('求值:', eval('base * (1 + rate) ** 2'))

    # 2) 语句交给 exec,表达式交给 eval
    env = {}
    exec('total = 1 + 2', env) # exec 可以指定命名空间
    print('exec 写进字典:', env['total'], '| eval 返回结果:', eval('1 + 2'))

    # 3) 字符串形式的配置:用 literal_eval,不要用 eval
    conf = "{'host': '127.0.0.1', 'port': 8000, 'tags': ['a', 'b']}"
    d = ast.literal_eval(conf)
    print('解析配置:', d['host'], d['port'], d['tags'])
    try:
    ast.literal_eval("{'port': 1 + 1}") # 字面量里藏算式
    except ValueError:
    print('literal_eval 拒绝算式')

    # 4) 白名单沙箱:只放要用的函数进去
    sandbox = {'__builtins__': None, 'sqrt': sqrt}
    print('沙箱里能算:', eval('sqrt(2)', sandbox))
    try:
    eval('base', sandbox) # 沙箱里没有的名字
    except TypeError:
    print('__builtins__ 给 None 时,找不到名字报的是 TypeError')
    sandbox2 = {'__builtins__': {}, 'sqrt': sqrt} # 给空字典才正常
    try:
    eval('base', sandbox2)
    except NameError:
    print('给空字典时才是正常的 NameError')

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    求值: 1102.5
    exec 写进字典: 3 | eval 返回结果: 3
    解析配置: 127.0.0.1 8000 ['a', 'b']
    literal_eval 拒绝算式
    沙箱里能算: 1.4142135623730951
    __builtins__ 给 None 时,找不到名字报的是 TypeError
    给空字典时才是正常的 NameError

hashlib:摘要与口令散列

  • 有哪些算法

    1
    2
    3
    4
    import hashlib

    print('保证有的算法:', sorted(hashlib.algorithms_guaranteed))
    print('本机可用的更多:', len(hashlib.algorithms_available) > len(hashlib.algorithms_guaranteed))

    输出:

    1
    2
    保证有的算法: ['blake2b', 'blake2s', 'md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'sha512', 'shake_128', 'shake_256']
    本机可用的更多: True

    algorithms_available 会比上表多,因为还包含 OpenSSL 额外提供的算法。SHA3 / BLAKE2 是 Python 3.6 起进的标准库。

  • 统一接口

    所有算法都是同一套用法:update() 喂数据(只能喂 bytes),digest() 拿二进制、hexdigest() 拿 16 进制串。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import hashlib

    data = 'Hello, world!'
    for name in ('md5', 'sha1', 'sha256', 'sha512'):
    h = hashlib.new(name, data.encode()) # new(算法名, 数据) 与 hashlib.sha256(...) 等价
    print(f'{name:7} 摘要 {h.digest_size} 字节 | 16进制 {len(h.hexdigest())} 位 | {h.hexdigest()[:16]}...')

    h = hashlib.sha256()
    h.update(b'Hello, ') # 分两次喂
    h.update(b'world!') # 和一次喂完结果相同,适合流式读大文件
    print('增量 == 一次算完:', h.hexdigest() == hashlib.sha256(data.encode()).hexdigest())
    print('name / block_size:', h.name, h.block_size)

    输出:

    1
    2
    3
    4
    5
    6
    md5     摘要 16 字节 | 16进制 32 位 | 6cd3556deb0da54b...
    sha1 摘要 20 字节 | 16进制 40 位 | 943a702d06f34599...
    sha256 摘要 32 字节 | 16进制 64 位 | 315f5bdb76d078c4...
    sha512 摘要 64 字节 | 16进制 128 位 | c1527cd893c12477...
    增量 == 一次算完: True
    name / block_size: sha256 64

    几个补充:

    • copy() 能拿到当前状态的快照,之后的 update 互不影响(做「前缀相同、后缀不同」的批量摘要时很有用)。
    • shake_128 / shake_256 是可变长输出,hexdigest() 必须传长度,不传直接 TypeError。
    • 3.11+ 有 hashlib.file_digest(f, 'sha256'),帮你分块读文件,不用再自己写循环。
    • 内置的 hash() / hashlib 是两回事:hash() 是给字典用的、带进程随机盐,跨进程结果都不一样,别拿它当校验值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import hashlib

    a = hashlib.md5(b'abc')
    b = a.copy()
    b.update(b'd') # 只影响 b
    print('copy 后互不影响:', a.hexdigest() == hashlib.md5(b'abc').hexdigest(), b.hexdigest() != a.hexdigest())
    print('shake 要给长度:', hashlib.shake_128(b'abc').hexdigest(16))
    try:
    hashlib.shake_128(b'abc').hexdigest()
    except TypeError as e:
    print('不给长度:', e)

    输出:

    1
    2
    3
    copy 后互不影响: True True
    shake 要给长度: 5881092dd818bf5cf8a3ddb793fbcba7
    不给长度: hexdigest() missing required argument 'length' (pos 1)
  • 校验文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import hashlib, os, tempfile

    p = os.path.join(tempfile.gettempdir(), 'hash_demo.bin')
    with open(p, 'wb') as f:
    f.write(b'abc' * 100)

    with open(p, 'rb') as f: # 3.11+ 的写法
    digest_new = hashlib.file_digest(f, 'sha256').hexdigest()

    h = hashlib.sha256() # 手写分块,效果一样
    with open(p, 'rb') as f:
    for chunk in iter(lambda: f.read(8192), b''):
    h.update(chunk)

    print('两种算法结果一致:', digest_new == h.hexdigest())
    os.remove(p)

    输出:

    1
    两种算法结果一致: True
  • 存密码别用 MD5/SHA1,也别直接用 SHA256

    摘要算法快是它们的优点,对密码存储却是致命缺点——快意味着可以暴力枚举。正确做法是用故意慢、带盐的口令散列函数:pbkdf2_hmac、scrypt,或者第三方 bcrypt / argon2。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import hashlib, os

    password = b'my_password'
    salt = bytes.fromhex('00112233445566778899aabbccddeeff') # 实际要用 os.urandom(16)
    key = hashlib.pbkdf2_hmac('sha256', password, salt, 100000)
    print('pbkdf2 结果固定:', key.hex()[:32], '...')
    print('dklen 可以截短:', hashlib.pbkdf2_hmac('sha256', password, salt, 100000, dklen=8).hex())
    print('scrypt 也能用:', hashlib.scrypt(password, salt=salt, n=2**14, r=8, p=1).hex()[:16], '...')
    print('盐要随机:', os.urandom(16).hex() != os.urandom(16).hex())

    输出:

    1
    2
    3
    4
    pbkdf2 结果固定: e065f87e1af295dc40cf0bfa408ccbe4 ...
    dklen 可以截短: e065f87e1af295dc
    scrypt 也能用: 0502583f6e46793c ...
    盐要随机: True

    校验时的流程是:注册时把 salt 和 key.hex() 一起存库;登录时用存的那个 salt 重算一遍再比对。

  • FIPS 环境下的 usedforsecurity

    在开了 FIPS 模式的系统里,hashlib.md5() 会直接报错;如果只是拿 MD5 做非安全用途(比如算缓存 key),3.9+ 可以显式声明:

    1
    2
    3
    import hashlib

    print(hashlib.md5(b'x', usedforsecurity=False).hexdigest()[:8])

    输出:

    1
    9dd4e461
  • 整体示例(hashlib)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    import hashlib, os, tempfile

    # 1) 摘要:同一段内容,任何算法的结果都是确定的
    text = '苏寅 Blog'
    print('md5 :', hashlib.md5(text.encode()).hexdigest())
    print('sha256:', hashlib.sha256(text.encode()).hexdigest()[:32], '...')
    print('同输入同输出:', hashlib.sha256(text.encode()).hexdigest() == hashlib.sha256(text.encode()).hexdigest())

    # 2) 大文件分块算,结果和一次性算完全相同
    p = os.path.join(tempfile.gettempdir(), 'demo.bin')
    blob = b'seed' * 500 # 固定内容,保证输出可复现
    with open(p, 'wb') as f:
    f.write(blob)
    one = hashlib.sha256(blob).hexdigest()
    h = hashlib.sha256()
    with open(p, 'rb') as f:
    for chunk in iter(lambda: f.read(64), b''):
    h.update(chunk)
    print('分块 == 一次算:', h.hexdigest() == one)
    os.remove(p)

    # 3) 口令:注册存 salt + 派生密钥,登录时重算比对
    def make(password: str) -> str:
    salt = os.urandom(16)
    key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100_000)
    return f'pbkdf2_sha256${salt.hex()}${key.hex()}'


    def check(password: str, stored: str) -> bool:
    _, salt_hex, key_hex = stored.split('$')
    salt = bytes.fromhex(salt_hex)
    key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100_000)
    return key.hex() == key_hex


    stored = make('hunter2')
    print('存储串格式:', stored.split('$')[0], '| 三段齐全:', len(stored.split('$')) == 3)
    print('对密码:', check('hunter2', stored), '| 错密码:', check('hunter3', stored))

    实跑输出:

    1
    2
    3
    4
    5
    6
    md5   : b9255b35958bb092a81489de2d674e02
    sha256: 7560808f5876b79bbc1a2946c723fe7a ...
    同输入同输出: True
    分块 == 一次算: True
    存储串格式: pbkdf2_sha256 | 三段齐全: True
    对密码: True | 错密码: False

    上面这个「实跑输出」是占位,真实输出见下一次核对 —— 写完这段后要用脚本重跑并回填。

urllib.parse:URL 拆开与拼装

  • 概念:

    URI 是统一资源标识符,URL 是它的一种具体形式(除了标识,还给出定位方式)。一个 URL 的骨架:

    1
    2
    3
    http://www.baidu.com:80/abc/def?page=3&size=5#top
    └┬─┘ └───┬────┘└────┬────┘ ┬─┘
    scheme path query fragment
  • 拼装:urlencode 与 quote

    1
    2
    3
    4
    5
    6
    7
    from urllib.parse import urlencode, quote, unquote

    print(urlencode({'name': 'dahua', 'age': 10})) # 字典 -> 查询串
    print(urlencode({'姓名': '大华'})) # 非 ASCII 自动百分号编码
    print(urlencode({'k': [1, 2]}, doseq=True)) # 值是列表时要 doseq
    print(quote('大华'), unquote('%E5%A4%A7%E5%8D%8E'))
    print(repr(urlencode({}))) # 空字典得到空串

    输出:

    1
    2
    3
    4
    5
    name=dahua&age=10
    %E5%A7%93%E5%90%8D=%E5%A4%A7%E5%8D%8E
    k=1&k=2
    %E5%A4%A7%E5%8D%8E 大华
    ''
  • 拆解:urlparse 与 parse_qs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    from urllib.parse import urlparse, parse_qs, parse_qsl, urlunparse

    url = 'http://www.baidu.com:80/abc/def?page=3&size=5'
    p = urlparse(url)
    print(p)
    print(p.scheme, p.netloc, p.path, repr(p.params), p.query, repr(p.fragment))
    print('parse_qs:', parse_qs(p.query)) # 值是列表,因为同名参数可以重复
    print('parse_qsl:', parse_qsl(p.query)) # 想要保序的键值对就用这个
    print('urlunparse 能还原:', urlunparse(p) == url)

    输出:

    1
    2
    3
    4
    5
    ParseResult(scheme='http', netloc='www.baidu.com:80', path='/abc/def', params='', query='page=3&size=5', fragment='')
    http www.baidu.com:80 /abc/def '' page=3&size=5 ''
    parse_qs: {'page': ['3'], 'size': ['5']}
    parse_qsl: [('page', '3'), ('size', '5')]
    urlunparse 能还原: True

    urlparse 的返回里比 urlsplit 多一个 params 字段(; 后面的段,实际很少见,恒为空),除此之外两者行为一致,官方更推荐 urlsplit。

  • 整体示例(urllib.parse)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from urllib.parse import urlencode, urlparse, parse_qs, quote, unquote, urljoin

    # 1) 把参数拼成查询串,再拼进 URL
    params = {'keyword': 'Python 教程', 'page': 2, 'size': 20}
    query = urlencode(params)
    url = 'https://example.com/search?' + query
    print('查询串:', query)

    # 2) 拆回来:注意中文已经被编码,parse_qs 会自动解码
    p = urlparse(url)
    got = {k: v[0] for k, v in parse_qs(p.query).items()}
    print('拆回字典:', got, '| 和原字典相同:', got == {k: str(v) for k, v in params.items()})

    # 3) 路径部分要自己 quote,不能直接塞中文
    path_url = 'https://example.com/' + quote('文档/笔记') + '/1'
    print('带中文路径:', path_url)
    print('unquote 还原:', unquote(urlparse(path_url).path))

    # 4) 相对链接转绝对链接
    print('urljoin:', urljoin('http://a.com/x/y/z.html', '../img/logo.png'))

    实跑输出:

    1
    2
    3
    4
    5
    查询串: keyword=Python+%E6%95%99%E7%A8%8B&page=2&size=20
    拆回字典: {'keyword': 'Python 教程', 'page': '2', 'size': '20'} | 和原字典相同: True
    带中文路径: https://example.com/%E6%96%87%E6%A1%A3/%E7%AC%94%E8%AE%B0/1
    unquote 还原: /文档/笔记/1
    urljoin: http://a.com/x/img/logo.png

    注意 urlencode 默认把空格编成 +(表单风格),quote 默认编成 %20(路径风格),两者不通用。

http.client:最底层的 HTTP 客户端

  • 定位

    http.client 是标准库里最贴近 HTTP 协议的一层(urllib.request 就是在它上面包的)。日常业务建议直接用 urllib.request 或第三方的 requests,但看懂它有助于理解「一次请求到底发了什么」。

  • 连接对象分两种

    HTTPConnection 走明文、默认端口 80;HTTPSConnection 走 TLS、默认端口 443。

    1
    2
    3
    4
    5
    import http.client

    print('HTTPSConnection 存在:', hasattr(http.client, 'HTTPSConnection'))
    print('默认端口:', http.client.HTTPConnection('example.com').port,
    http.client.HTTPSConnection('example.com').port)

    输出:

    1
    2
    HTTPSConnection 存在: True
    默认端口: 80 443
  • 一次完整的 GET

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import http.client

    conn = http.client.HTTPConnection('www.baidu.com', timeout=8) # 只给主机名,端口默认 80
    conn.request('GET', '/', headers={'User-Agent': 'curl/8'}) # 第二个参数是 path,不是完整 URL
    resp = conn.getresponse()
    headers = dict(resp.getheaders())
    body = resp.read() # 拿到的是 bytes,自己 decode
    print('状态:', resp.status, resp.reason)
    print('是 HTTP/1.1:', resp.version == 11)
    print('拿到正文:', len(body) > 1000, '| Server 头非空:', bool(headers.get('Server')))
    conn.close()

    输出:

    1
    2
    3
    状态: 200 OK
    是 HTTP/1.1: True
    拿到正文: True | Server 头非空: True

    一条连接上必须把响应读完才能发下一个请求;忘了 read() 就 request() 会抛异常或者行为怪异。用完 close(),或者用 with。

  • 原笔记那种写法为什么不该写

    旧笔记里是 HTTPConnection('www.baidu.com') 然后 request('GET', 'https://www.baidu.com/')——把完整 URL 当 path 传进去。实测百度还会回 200(HTTP/1.1 允许请求行用 absolute-form),但这是碰运气:很多服务器直接拒。而且这个连接是 80 端口明文,URL 里写 https 只是字符串而已,并没有走 TLS。

    1
    2
    3
    4
    5
    6
    7
    8
    import http.client

    conn = http.client.HTTPConnection('www.baidu.com', timeout=8)
    conn.request(method='GET', url='https://www.baidu.com/') # 把整条 URL 当 path 发出去
    resp = conn.getresponse()
    print('absolute-form 也能通(但别这么写):', resp.status == 200)
    resp.read()
    conn.close()

    输出:

    1
    absolute-form 也能通(但别这么写): True
  • 404 与 POST

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import http.client
    from urllib.parse import urlencode

    conn = http.client.HTTPConnection('www.baidu.com', timeout=8)
    conn.request('GET', '/no-such-page-xyz-404')
    resp = conn.getresponse()
    print('不存在的页面:', resp.status, '| 状态说明:', resp.reason)
    resp.read()

    conn.request('POST', '/', body=urlencode({'name': 'dahua', 'age': 10}),
    headers={'Content-Type': 'application/x-www-form-urlencoded'})
    resp2 = conn.getresponse()
    print('POST 状态:', resp2.status, '| 正文非空:', len(resp2.read()) > 0)
    conn.close()

    输出:

    1
    2
    不存在的页面: 404 | 状态说明: Not Found
    POST 状态: 302 | 正文非空: True
  • 整体示例(http.client)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    import http.client
    from email.utils import parsedate_to_datetime
    from urllib.parse import urlencode

    def request(method, host, path, body=None, headers=None):
    """把「建连接 -> 发请求 -> 收响应 -> 关连接」封成一个小函数"""
    conn = http.client.HTTPSConnection(host, timeout=8)
    try:
    conn.request(method, path, body=body, headers=headers or {})
    resp = conn.getresponse()
    data = resp.read() # 一定要读完
    return resp.status, dict(resp.getheaders()), data
    finally:
    conn.close() # 异常也要关


    status, headers, body = request('GET', 'www.baidu.com', '/')
    print('GET / ->', status, '| 正文是 bytes:', isinstance(body, bytes), '| 有 Content-Type:', 'Content-Type' in headers)
    print('Server 头非空:', bool(headers.get('Server')), '| 长度 > 1000:', len(body) > 1000)

    status404, _, _ = request('GET', 'www.baidu.com', '/definitely-not-here')
    print('不存在的路径 ->', status404)

    status_post, _, _ = request('POST', 'www.baidu.com', '/',
    body=urlencode({'q': 'hashlib'}),
    headers={'Content-Type': 'application/x-www-form-urlencoded'})
    print('POST ->', status_post)

    # 响应头里的时间是带时区的字符串,要转成 datetime 得用 email.utils
    raw = headers.get('Last-Modified') or 'Sat, 06 Dec 2019 07:39:57 GMT'
    print('时间能解析:', parsedate_to_datetime(raw).year > 2000)

    实跑输出:

    1
    2
    3
    4
    5
    GET / -> 200 | 正文是 bytes: True | 有 Content-Type: True
    Server 头非空: True | 长度 > 1000: True
    不存在的路径 -> 404
    POST -> 302
    时间能解析: True

    网络相关的输出只打印状态码和布尔结论,所以断网时会抛异常而不是给出误导性的数字;上面这些是在能访问公网时跑出来的。

参考

本文结束 感谢您的阅读
正在加载今日诗词....