Python 网络编程与 Web 开发:逐行精讲教程(零基础版)
阶段一:Python 基础语法精讲
1.1 拆包与组包
# 这一行创建了一个元组(tuple)。元组是不可修改的列表,用圆括号包裹。
# 为什么要不可修改?因为"学生信息"这类数据不应该在程序运行中被意外篡改。
student = ('张三', 20, '广东深圳')
# 这一行叫做"序列解包"(Sequence Unpacking)。
# Python 看到左边有 3 个变量,右边有 3 个元素,就会自动按位置一一对应赋值。
# 为什么叫拆包?因为把容器(student)像拆包裹一样打开了。
name, age, place = student
# print 是 Python 的内置函数,负责把内容输出到屏幕。
# 为什么 name 能直接输出?因为上一行已经把它从元组里"拆"出来了。
print(name, age, place)
语法底层逻辑: Python 的赋值语句本质上不是"把右边给左边",而是**“把左右两边的结构对齐”**。左边 3 个坑,右边 3 个萝卜,编译器就知道怎么摆。如果左边 2 个变量右边 3 个元素,Python 会报错 ValueError: too many values to unpack。
# 带星号的表达式(Python 3 特性)
t = (1, 2, 3, 4, 5)
# x 拿第一个,z 拿最后一个,*_ 表示"中间所有不要的元素"
# 下划线 _ 在 Python 社区约定中表示"临时/无用的变量"
x, *_, z = t
# 为什么 *_ 能吞掉多个?因为星号 * 在赋值左侧表示"贪婪匹配",把所有剩余元素收进一个列表。
# 如果不加 *,写成 x, _, z = t 会报错,因为左边3个右边5个,数量对不上。
# **kwargs 的本质:字典拆成关键字参数
def fun(**kwargs):
# **kwargs 在函数定义中表示"接收任意个数的关键字参数"
# 这些参数会被打包成一个字典:{'name': '张三', 'age': 21}
print(kwargs)
# 先定义一个字典
dic = {'name': '张三', 'age': 21, 'place': '深圳'}
# fun(**dic) 中的 ** 表示"把字典拆开"
# 等价于 fun(name='张三', age=21, place='深圳')
# 为什么要这样?因为有时候参数存在字典里,你需要传给函数。
fun(**dic)
如果不加 ** 会怎样? fun(dic) 会把整个字典作为一个参数传给 kwargs,结果 kwargs 变成 {'name': {'name': '张三'...}},嵌套了一层,逻辑全错。
1.2 函数参数
# def 是 define(定义)的缩写,用来创建函数。
# append_jj 是函数名,括号里的是"形式参数"(形参),只是占位符,没有具体值。
# 为什么要定义函数?把重复逻辑封装起来,避免写一万行重复代码。
def append_jj(list_jj, element_jj):
# list_jj.append() 调用的是 Python 列表对象的内置方法。
# 注意:列表是可变对象,append 是在原列表上修改,不创建新列表。
# 为什么不用 list_jj = list_jj + [element_jj]?
# 因为 + 会创建新列表,浪费内存;append 是原地修改,效率更高。
list_jj.append(element_jj)
# return 表示"把结果扔回调用处"。
# 虽然列表是原地修改的,但返回它可以让调用者链式操作:append_jj(a, b).append(c)
return list_jj
# 这里 ['hello', 'world'] 是"实际参数"(实参)。
# Python 传参的机制是"传对象引用":list_jj 和外面的列表指向内存中同一个地址。
# 所以函数内修改,函数外也能看到变化。
print(append_jj(['hello', 'world'], 'python'))
默认参数的"陷阱":
# element_jj='python' 是默认参数。
# 注意!默认参数在函数定义时就被计算了一次,存在函数的 __defaults__ 属性里。
def append_jj(list_jj, element_jj='python'):
list_jj.append(element_jj)
return list_jj
# 调用时不给第二个参数,Python 就去默认值里找,拿到 'python'。
print(append_jj(['hello'])) # 输出 ['hello', 'python']
# 调用时给了参数,默认值就被覆盖了。
print(append_jj(['hello'], 'java')) # 输出 ['hello', 'java']
⚠️ 千万别用可变对象做默认参数!
# 这是经典大坑!
def bad_func(items=[]):
items.append(1)
return items
print(bad_func()) # [1]
print(bad_func()) # [1, 1] —— 因为两次调用的是同一个列表对象!
1.3 面向对象
# class 是关键字,表示"我要定义一个蓝图(类)了"。
# NetSecGuard 是类名,通常用大驼峰命名法(每个单词首字母大写)。
class NetSecGuard:
# __init__ 是"构造方法",每次创建对象时自动执行。
# 为什么叫 __init__?这是 Python 的魔法方法(dunder method),双下划线表示"由解释器特殊处理"。
# self 是什么?self 代表"当前正在创建的这个对象本身"。
# 为什么必须写 self?因为类是蓝图,self 让方法知道"我现在是在操作哪一栋具体的房子"。
def __init__(self, name, work_id, guard_field):
# self.name = name 表示"给这个对象贴一个标签叫 name,值是传进来的 name"。
# 左边 self.name 是对象属性,右边 name 是函数参数。
self.name = name
self.work_id = work_id
self.guard_field = guard_field
# __str__ 也是魔法方法,当你 print(对象) 或 str(对象) 时自动调用。
# 为什么要定义它?否则 print(guard01) 会输出一堆内存地址,人类看不懂。
def __str__(self):
return f"姓名:{self.name} | 工号:{self.work_id}"
# 普通成员方法。调用时 self 不用传,Python 自动把对象塞进去。
def safety_inspection(self):
# f-string(格式化字符串)是 Python 3.6+ 特性,f"..." 里的大括号可以嵌入变量。
# 为什么不用 + 拼接字符串?因为 f-string 更快、更易读。
print(f"【{self.name}】正在执行巡检...")
# 这一行叫做"实例化":根据 NetSecGuard 这个蓝图,造出一个具体对象 guard01。
# 括号里的参数会传给 __init__ 的 name, work_id, guard_field。
guard01 = NetSecGuard("张三", "202301", "校园网")
# print 会自动调用 guard01.__str__(),所以输出的是我们定义好的格式。
print(guard01)
1.4 模块导入
# import 模块名:把整个文件加载进来,调用时需要加前缀。
# Python 会在 sys.path 列表里的路径中查找 netsec_module.py。
import netsec_module
guard = netsec_module.NetSecGuard("李四", "202302", "机房")
# from ... import ...:只把指定的类/函数从文件里"摘"出来,直接用,不用加模块名前缀。
# 为什么可以这样?因为 Python 模块本质上就是一个字典(module.__dict__),from import 只是做了局部变量绑定。
from netsec_module import NetSecGuard
guard = NetSecGuard("李四", "202302", "机房")
为什么有时候要 if __name__ == '__main__':? 当一个 .py 文件被直接运行时,__name__ 变量的值是 '__main__';当它被别的文件 import 时,__name__ 的值是模块名。这个判断让你既可以当脚本运行,又可以当模块导入,测试代码不会在被导入时执行。
阶段二:Socket 网络编程精讲
2.1 TCP 通信
服务器端逐行精讲:
# socket 是 Python 标准库,提供操作系统底层网络接口的封装。
# 为什么叫 socket(套接字)?源自 Unix 的"插座"概念,IP+端口就像电源插孔。
import socket
# socket.socket() 是构造函数,创建了一个"通信端点"对象。
# AF_INET 表示 Address Family - Internet,即 IPv4 地址族。
# 为什么不直接写字符串?因为操作系统底层用常量标识协议族,AF_INET 在 C 语言里就是 2。
# SOCK_STREAM 表示"流式套接字",即 TCP。TCP 像水流一样连续、可靠。
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind() 把套接字绑定到一个具体的地址和端口。
# ('127.0.0.1', 8888) 是一个元组:IP 地址 + 端口号。
# 127.0.0.1 是回环地址(loopback),数据不经过网卡,只在操作系统内部转一圈,适合本机测试。
# 端口号 8888 是 16 位整数,范围 0-65535。0-1023 是知名端口(需要管理员权限),所以我们用 8888。
server_socket.bind(('127.0.0.1', 8888))
# listen(5) 让套接字进入"监听状态",变成被动方,等待别人连接。
# 参数 5 是"半连接队列长度",表示最多允许多少个客户端在排队(还没被 accept)。
# 为什么需要排队?因为服务器一次只能 accept 一个,如果同时来 10 个客户端,没排队的会被拒绝。
server_socket.listen(5)
# accept() 是阻塞方法(blocking),程序会停在这里,直到有客户端敲门。
# 它返回两个东西:client_socket(用于和该客户端通信的新套接字)和 client_addr(客户端的 IP+端口)。
# 为什么返回新的套接字?因为 server_socket 要继续监听大门,不能用来聊天,所以操作系统克隆了一个专门的套接字给这个客户端。
client_socket, client_addr = server_socket.accept()
# recv(1024) 从接收缓冲区拿数据。1024 是"最多拿多少字节"。
# 为什么不是精确值?因为 TCP 是流,你不知道对方一次发多少,1024 只是上限。
# 返回的是 bytes 类型(二进制),因为网络传输的都是 0/1,不知道编码。
data = client_socket.recv(1024)
# decode('utf-8') 把二进制 bytes 解码成人类能看懂的字符串。
# UTF-8 是 Unicode 的一种编码方式,一个汉字占 3 个字节。
# 如果不解码直接 print,会看到 b'xxx' 前缀;如果编码错了,会看到乱码。
print(data.decode('utf-8'))
# send() 发送数据,但必须发 bytes,所以要把字符串 encode() 成二进制。
client_socket.send("收到!".encode('utf-8'))
# close() 释放操作系统资源(文件描述符)。
# 如果不 close,端口会被一直占用,直到进程结束。在 Linux 下还可能进入 TIME_WAIT 状态。
client_socket.close()
server_socket.close()
客户端逐行精讲:
import socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# connect() 向服务器发起连接请求,触发 TCP 三次握手。
# 三次握手是操作系统内核自动完成的:SYN → SYN+ACK → ACK。
# 如果服务器没开,这里会抛出 ConnectionRefusedError。
client_socket.connect(('127.0.0.1', 8888))
# 发消息前必须 encode,因为 socket 只认二进制。
client_socket.send("你好".encode('utf-8'))
# 客户端也要 recv,因为 TCP 是全双工的,双方都能收能发。
response = client_socket.recv(1024)
print(response.decode('utf-8'))
client_socket.close()
2.2 持续运行与异常处理
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(5)
# while True 创建无限循环。服务器理论上要 7×24 小时运行,不能处理一个客户端就退出。
# 为什么服务器用 '0.0.0.0'?表示"本机所有网卡",包括 127.0.0.1 和局域网 IP、公网 IP。
# 如果用 127.0.0.1,只有本机能连;用 0.0.0.0,谁都能连(防火墙允许的前提下)。
print("服务器运行中...")
while True:
client, addr = server.accept()
print(f"新连接:{addr}")
# try-except-finally 是异常处理结构。
# 为什么需要?因为客户端可能突然断网、强制关闭,导致 recv 抛出异常,服务器不能因此崩溃。
try:
data = client.recv(1024)
if data:
print(f"收到:{data.decode()}")
client.send("OK".encode())
except Exception as e:
# 捕获所有异常,打印但不崩溃。
print(f"出错:{e}")
finally:
# finally 块无论是否异常都会执行,确保资源一定释放。
# 如果不写 finally,异常发生时 client.close() 会被跳过,导致资源泄漏。
client.close()
2.3 UDP 通信
import socket
# SOCK_DGRAM 中 DGRAM 是 Datagram(数据报)的缩写。
# UDP 不建立连接,每个数据报都是独立的,像一封信,可能丢、可能乱序、可能重复。
udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# UDP 也要 bind,因为服务器要固定在一个端口等信。
udp_server.bind(('127.0.0.1', 9999))
# recvfrom() 是 UDP 专用方法,因为 UDP 没有连接,你不知道信是谁发的,所以返回 (数据, 发送方地址)。
# TCP 用 recv(),因为连接已经确定了对方是谁。
data, addr = udp_server.recvfrom(1024)
# sendto() 必须指定地址,因为 UDP 没有"连接"的概念,每次发送都是独立的。
udp_server.sendto("收到!".encode(), addr)
阶段三:Socket 进阶与多线程
3.1 多线程并发服务器
import socket
import threading # threading 是 Python 标准库,提供高层线程接口。
def handle_client(client_socket, client_addr):
"""
这个函数专门伺候一个客户端。
为什么要写成函数?因为每个线程都要执行一段独立逻辑,函数是代码复用的最小单位。
"""
print(f"[新线程] 服务 {client_addr}")
try:
while True:
# recv 默认是阻塞的:没有数据来,线程就挂起(休眠),不占用 CPU。
# 为什么不会卡死别的客户端?因为每个客户端在独立线程里,操作系统调度器会让它们轮流执行。
data = client_socket.recv(1024)
# 如果客户端正常关闭连接,会发送一个 FIN 包,recv 返回空字节 b''。
# 为什么要判断 if not data?因为空字节表示"对面挂了",我们要退出循环。
if not data:
break
# 解码、处理、再编码发回去。
msg = data.decode('utf-8')
reply = f"服务器复读机:{msg}"
client_socket.send(reply.encode('utf-8'))
except Exception as e:
print(f"[{client_addr}] 异常:{e}")
finally:
client_socket.close()
def main():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(5)
while True:
client, addr = server.accept()
# threading.Thread 创建一个线程对象。
# target=handle_client 表示"线程启动后去执行这个函数"。
# args=(client, addr) 表示给这个函数传的参数,必须是元组。
# 为什么用元组?因为位置参数就是按顺序排的,元组是有序不可变的。
thread = threading.Thread(target=handle_client, args=(client, addr))
# start() 真正启动线程,向操作系统申请资源。
# 为什么不直接调用 handle_client()?因为直接调用是在当前线程顺序执行,start() 才是并行执行。
thread.start()
# threading.active_count() 返回当前存活线程数(包括主线程)。
print(f"活跃线程:{threading.active_count()}")
if __name__ == '__main__':
main()
线程的底层: Python 的 threading 实际上封装了操作系统的线程(Windows 是 Win32 Thread,Linux 是 pthread)。操作系统把 CPU 时间切成小片,轮流给每个线程,宏观上就像同时在运行。
3.2 线程锁
import threading
# 0 是全局变量,存在于所有线程都能访问的内存空间(进程全局区)。
number = 0
# Lock 对象内部有一个标志位:True(已锁定)/ False(未锁定)。
# 它底层调用操作系统原语(如 Linux 的 futex),保证"检查+锁定"是原子操作。
lock = threading.Lock()
def plus():
global number # global 声明"我要修改外面的全局变量,不是创建局部变量"。
# acquire() 尝试拿锁。如果锁被别人拿着,当前线程就阻塞(排队等待)。
# 为什么要排队?因为 number += 1 在 CPU 层面是 3 条指令(读、加、写),中间可能被别的线程打断。
lock.acquire()
try:
# 现在只有拿到锁的线程能执行这里,其他线程都在 acquire() 处睡觉。
for _ in range(1000000):
number += 1
finally:
# release() 释放锁。一定要记得放,否则别的线程永远进不来,叫"死锁"。
# 为什么放 finally?因为即使中间报错,锁也能被释放。
lock.release()
# with 语句是更优雅的写法,它自动处理 acquire 和 release。
def plus_safe():
global number
with lock: # 进入时 __enter__() 调用 acquire,离开时 __exit__() 调用 release。
for _ in range(1000000):
number += 1
为什么 number += 1 不是原子的? Python 的 += 会被编译成 3 条字节码:LOAD_GLOBAL(读)、INPLACE_ADD(加)、STORE_GLOBAL(写)。线程 A 刚读完(number=5),CPU 切换到线程 B,B 也读(number=5),各自加 1 都变成 6,写回去。结果两个线程只加了 1,这就是竞态条件(Race Condition)。
3.3 Event 事件锁
import threading
import time
# Event 内部维护一个 threading.Event 对象,核心是 _flag 布尔值。
# 它是线程间最简单的信号机制:一个线程发信号,其他线程等信号。
event = threading.Event()
def lighter():
while True:
print("绿灯亮")
event.set() # set() 把 _flag 设为 True,所有在 wait() 的线程被唤醒。
time.sleep(5)
print("红灯亮")
event.clear() # clear() 把 _flag 设为 False。
time.sleep(5)
def car(name):
while True:
# is_set() 检查当前状态,不阻塞。
if event.is_set():
print(f"[{name}] 开过")
time.sleep(1)
else:
print(f"[{name}] 看到红灯,停下")
# wait() 是阻塞等待,内部会释放 GIL(全局解释器锁),让别的线程运行。
# 直到别的线程调用了 set(),这个线程才被唤醒。
event.wait()
print(f"[{name}] 启动!")
3.4 文件传输整合
import socket
import os
def run_server(host='0.0.0.0', port=9000):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((host, port))
server.listen(5)
while True:
client, addr = server.accept()
# 先收命令。我们约定:所有控制指令都用 ASCII 字符串,以固定格式发送。
# 为什么先发命令?因为对方要告诉我是"上传"还是"下载",否则我不知道该读文件还是写文件。
cmd = client.recv(1024).decode()
if cmd.startswith("UPLOAD"):
# split('|') 按竖线分割字符串。竖线在文件名中很少见,适合做分隔符。
# maxsplit=2 限制最多分 2 次,防止文件名里意外出现 | 导致错乱。
_, filename, filesize = cmd.split('|', 2)
filesize = int(filesize)
# with 语句是上下文管理器,自动调用 f.close(),即使中途报错也会关文件。
# 'wb' 中 w 表示写,b 表示二进制。图片/视频必须用二进制,不能用文本模式。
with open(f"uploads/{filename}", 'wb') as f:
received = 0
# 为什么要循环 recv?因为 TCP 是流,一次 recv 不一定能收完整个文件。
# 比如 10MB 文件,recv(4096) 一次最多收 4KB,需要收很多次。
while received < filesize:
chunk = client.recv(4096)
# 如果对方中途断网,recv 可能返回空,表示连接断了。
if not chunk:
break
f.write(chunk)
received += len(chunk) # 累加已接收字节数
client.send("上传成功!".encode())
elif cmd.startswith("DOWNLOAD"):
_, filepath = cmd.split('|', 1)
# os.path.exists 检查文件是否存在,防止客户端要求下载不存在的文件。
if os.path.exists(filepath):
filesize = os.path.getsize(filepath) # 获取文件字节大小
# 先发元信息(大小),让客户端知道要收多少。
client.send(f"OK|{filesize}".encode())
with open(filepath, 'rb') as f:
while True:
chunk = f.read(4096) # 每次读 4KB,避免大文件一次性读爆内存。
if not chunk:
break
client.send(chunk)
else:
client.send("ERROR|文件不存在".encode())
client.close()
阶段四:端口扫描器精讲
4.1 TCP 全连接扫描
import socket
import sys
from datetime import datetime
def tcp_full_connect_scan(target, start_port, end_port):
# "-" * 50 是字符串乘法,生成 50 个横线,用来做视觉分隔。
print("-" * 50)
print(f"目标: {target}")
# datetime.now() 获取当前系统时间,用于计算扫描耗时。
print(f"开始时间: {datetime.now()}")
open_ports = [] # 空列表,用来收集结果。
try:
# range(start, end+1) 生成从 start 到 end 的整数序列。
# 为什么 end+1?因为 range 是左闭右开,range(1,3) 只给 1,2。
for port in range(start_port, end_port + 1):
# 每次循环都新建 socket。为什么不在外面建一个复用?
# 因为 connect 后 socket 就和一个特定端口绑定了,不能再用它连别的端口。
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# settimeout(0.5) 设置阻塞操作的超时时间。
# 如果不设,对方没响应时 recv/connect 会永远等下去,程序卡死。
s.settimeout(0.5)
# connect_ex() 是 connect() 的安全版本。
# connect() 失败会抛出异常(需要 try-except);connect_ex() 返回错误码数字,0 表示成功。
# 为什么用 0 表示成功?这是 Unix 传统:0 是成功,非 0 是各种错误编号。
result = s.connect_ex((target, port))
if result == 0:
print(f"[+] 端口 {port} 开放")
open_ports.append(port) # append 往列表尾部加元素。
# 必须 close!每个 socket 占用一个文件描述符(fd),Linux 默认最多 1024 个。
# 如果不关,扫到 1000 个端口后程序会报错 "Too many open files"。
s.close()
except KeyboardInterrupt:
# KeyboardInterrupt 是用户按 Ctrl+C 时抛出的特殊异常。
# sys.exit() 立即终止进程,返回操作系统一个退出码。
print("\n用户终止")
sys.exit()
except socket.gaierror:
# gai = getaddrinfo(),是 DNS 解析失败的错误。
print("\nIP/域名解析失败")
sys.exit()
4.2 多线程优化扫描
from concurrent.futures import ThreadPoolExecutor
# concurrent.futures 是 Python 3.2+ 标准库,提供高级异步执行接口。
# ThreadPoolExecutor 是"线程池":预先创建一堆线程,任务来了分配给空闲线程。
def fast_scan(target, start, end, max_workers=100):
open_ports = []
# with 语句管理线程池生命周期,退出时自动关闭所有线程。
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 字典推导式:给每个端口提交一个扫描任务,记录 {future对象: 端口号}
# submit(fn, *args) 把函数 fn 扔进线程池排队/执行,返回 Future 对象。
future_to_port = {
executor.submit(scan_port, target, port): port
for port in range(start, end + 1)
}
# as_completed 在任务完成时 yield 出来,不用等所有任务结束。
for future in future_to_port:
# future.result() 获取函数返回值。如果函数抛异常,result() 会重新抛出它。
result = future.result()
if result:
open_ports.append(result)
阶段五:数据格式处理精讲
5.1 XML 解析
import xml.etree.ElementTree as ET
# ElementTree 是 Python 内置的轻量级 XML 解析器,适合中小文件。
# parse() 把整个 XML 文件读进内存,构建一棵 ElementTree。
# 为什么叫树?因为 XML 天然是嵌套结构:<root><child><grandchild/></child></root>。
tree = ET.parse('movies.xml')
# getroot() 获取根元素,即最外层的标签。
root = tree.getroot()
# iter('year') 遍历整棵树,找到所有叫 'year' 的标签,不管它在哪一层。
# 为什么不用 findall?findall 只在直接子元素里找,iter 是深度优先遍历整棵树。
for year in root.iter('year'):
print(year.text) # .text 获取标签包裹的文本内容。
# 写入 XML
root = ET.Element('students') # 创建根节点
# SubElement(parent, tag) 给 parent 添加一个子节点。
student = ET.SubElement(root, 'student')
student.set('id', '001') # set() 添加属性,即 <student id="001">。
name = ET.SubElement(student, 'name')
name.text = '张三' # 设置文本内容。
# write() 把内存中的树写入硬盘。
# xml_declaration=True 会在第一行加上 <?xml version='1.0' encoding='utf-8'?>。
# 为什么需要声明?告诉解析器文件编码,否则中文可能乱码。
tree = ET.ElementTree(root)
tree.write('output.xml', encoding='utf-8', xml_declaration=True)
5.2 CSV 读写
import csv
# open() 打开文件,返回文件对象。
# newline='' 是 Python 3 的推荐写法,防止在 Windows 下每行后面多一个空行。
# 为什么 Windows 会多空行?因为 Windows 的换行是 \r\n,csv 模块自己也会处理换行,双重处理导致空行。
with open('output.csv', 'w', newline='', encoding='gbk') as f:
# csv.writer 封装了 CSV 格式规则:逗号分隔、引号包裹含逗号的字段等。
# 为什么不直接 f.write('a,b,c')?因为如果你的数据里本身有逗号或换行,手动拼接会错乱。
csvwriter = csv.writer(f)
# writerow() 接收一个列表,自动处理转义。
# 比如 ['a,b', 'c'] 会被写成 "a,b",c(带引号)。
csvwriter.writerow(['学号', '姓名', '分数'])
# writerows() 接收二维列表,一次性写多行,内部循环调用 writerow。
csvwriter.writerows([
['17001', '张三', '80'],
['17002', '李四', '90']
])
阶段六:网络爬虫精讲
6.1 Requests 与 HTTP
import requests
# GET 是 HTTP 方法之一,表示"我要获取资源"。
# 浏览器地址栏回车就是 GET。
# requests.get() 底层封装了 socket、TCP 连接、HTTP 请求报文组装。
r = requests.get('http://example.com')
# status_code 是 HTTP 响应状态码,三位数字。
# 2xx 成功,3xx 重定向,4xx 客户端错误(如 404 找不到),5xx 服务器错误。
print(r.status_code)
# text 是自动解码后的字符串。requests 会猜编码(从 HTTP 头或内容分析)。
# content 是原始 bytes,适合下载图片/视频。
print(r.text)
# headers 是请求头字典,模拟浏览器身份。
# 很多网站会检查 User-Agent,如果是空或像爬虫,直接拒绝(403 Forbidden)。
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
r = requests.get('http://example.com', headers=headers)
6.2 BeautifulSoup 解析
from bs4 import BeautifulSoup
# 'lxml' 是解析器名称。lxml 是 C 语言写的高效库,比 Python 自带的 html.parser 快。
# 为什么需要解析器?因为 HTML 是文本,我们要把它变成可操作的对象树。
soup = BeautifulSoup(html_text, 'lxml')
# find('h1') 找到第一个 <h1> 标签,返回 Tag 对象。
# 为什么只找第一个?因为网页通常只有一个大标题。
title = soup.find('h1').text
# find_all('p') 找到所有 <p> 标签,返回列表。
# 为什么返回列表?因为段落一般有多个。
paragraphs = soup.find_all('p')
# CSS 选择器:select('.content') 找 class="content" 的所有元素。
# . 表示 class,# 表示 id。这是前端 CSS 的语法,BeautifulSoup 借用了。
items = soup.select('p.content')
6.3 大学排名爬虫
import requests
from bs4 import BeautifulSoup
class UniInfo:
def __init__(self, id, school, score):
# 为什么用 __init__?因为创建对象时必须初始化数据,否则对象处于无效状态。
self.id = id
self.school = school
self.score = score
def __str__(self):
# format 里的 ^ 表示居中对齐,10 表示占 10 个字符宽度。
# \t 是制表符,让输出对齐成表格。
return "{:^10}\t{:^25}\t{:^20}".format(self.id, self.school, self.score)
def get_uni_ranking(url):
headers = {'User-Agent': 'Mozilla/5.0...'}
# timeout=10 表示如果 10 秒内服务器没响应,抛出异常。
# 为什么需要超时?防止对方服务器卡死导致你的程序永远挂起。
r = requests.get(url, headers=headers, timeout=10)
# raise_for_status() 是偷懒神器:如果不是 200,自动抛 HTTPError。
# 等价于 if r.status_code != 200: raise Exception(...)
r.raise_for_status()
# apparent_encoding 通过分析内容猜测编码,比 headers 里声明的更准。
r.encoding = r.apparent_encoding
soup = BeautifulSoup(r.text, 'html.parser')
uni_list = []
# 找 <tbody> 里的所有 <tr>(表格行)。
# 为什么先找 tbody?因为 thead 是表头,我们不需要。
for tr in soup.find('tbody').find_all('tr'):
tds = tr.find_all('td') # 行里的所有单元格
# 为什么检查 len(tds) >= 3?因为有些行可能是广告或合并单元格,数据不全,跳过防止索引越界。
if len(tds) >= 3:
id = tds[0].text.strip() # .strip() 去掉首尾空格和换行。
school = tds[1].text.strip()
score = tds[2].text.strip()
uni_list.append(UniInfo(id, school, score))
return uni_list
6.4 电影热榜与图片下载
import requests
import os
def get_img(url, path, name):
headers = {'User-Agent': 'Mozilla/5.0...'}
# 为什么图片也要加 headers?因为有些图片服务器会检查 Referer,防止盗链。
r = requests.get(url, headers=headers, timeout=10)
r.raise_for_status()
# os.makedirs 递归创建目录(如果 uploads/2024/ 不存在,一次性全建)。
# exist_ok=True 表示如果目录已存在,不报错。默认 False 会抛 FileExistsError。
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
# url.split('.') 按点分割,[-1] 取最后一段,通常是 jpg/png。
# split('?')[0] 是为了去掉 URL 后面的参数(如 ?width=300)。
ext = url.split('.')[-1].split('?')[0]
# 'wb':w 是写,b 是二进制。图片是二进制文件,绝对不能写文本模式(w)。
# 如果用 'w',Python 会尝试按编码解释字节流,图片直接损坏。
with open(f"{path}/{name}.{ext}", 'wb') as f:
f.write(r.content) # r.content 是 bytes,直接写入。
阶段七:Django Web 开发精讲
7.1 安装与项目结构
# django-admin 是 Django 的命令行工具,随 Django 一起安装。
# startproject 命令会创建项目骨架:配置文件、路由、WSGI 入口。
django-admin startproject blog_jj
# manage.py 是 Django 的"瑞士军刀",所有操作都通过它。
# startapp 创建一个应用。Django 鼓励"一个项目多个应用"的架构。
# 比如博客项目可以有 app01(文章)、app02(用户)、app03(支付)。
python manage.py startapp app01
# runserver 启动开发服务器。它不是生产服务器(不能承受高并发),只是本地调试用的。
# 它自带热重载:你改代码后自动重启,不用手动关开。
python manage.py runserver
# makemigrations 检测 models.py 的变更,生成迁移脚本(在 app01/migrations/ 里)。
# migrate 把迁移脚本翻译成 SQL,在数据库里真正建表/改表。
python manage.py makemigrations
python manage.py migrate
7.2 配置文件
# settings.py
INSTALLED_APPS = [
# Django 内置应用:后台管理、认证系统、内容类型框架。
'django.contrib.admin',
'django.contrib.auth',
...
# 为什么要注册 app01?因为 Django 是"插件式"架构,只有注册了的应用,
# Django 才会去加载它的 models、模板、静态文件。
'app01',
]
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# DIRS 告诉 Django 去哪里找模板文件。
# BASE_DIR 是项目根目录(包含 manage.py 的目录)。
# / 在 Path 对象里是跨平台的(Windows 是 \,Linux 是 /,Path 自动处理)。
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True, # 是否在每个应用的 templates 子目录里找。
}]
DATABASES = {
'default': {
# ENGINE 指定数据库后端。Django 支持 SQLite、MySQL、PostgreSQL、Oracle。
# 为什么用字符串?Django 通过 importlib 动态导入对应模块。
'ENGINE': 'django.db.backends.mysql',
'NAME': 'study_blog', # 数据库名
'USER': 'root',
'PASSWORD': 'root',
'HOST': '127.0.0.1',
'PORT': 3306, # MySQL 默认端口
}
}
# 为什么 AUTH_USER_MODEL 要这样写?
# Django 自带的 User 模型字段太少(只有用户名、密码、邮箱)。
# 通过继承 AbstractUser,我们可以加字段(如昵称、头像),同时保留 Django 的认证逻辑。
# "app01.UserInfo" 是"应用名.模型名"的格式,Django 靠这个字符串做懒加载。
AUTH_USER_MODEL = "app01.UserInfo"
7.3 数据模型
from django.db import models
from django.contrib.auth.models import AbstractUser
# models.Model 是 Django ORM 的基类。ORM = Object-Relational Mapping(对象关系映射)。
# 它的作用:你用 Python 类和对象操作数据,Django 自动翻译成 SQL。
# 比如 UserInfo.objects.create(name='a') 会被翻译成 INSERT INTO app01_userinfo ...
class UserInfo(AbstractUser):
# AutoField 是自增整数主键。primary_key=True 表示这是主键。
# 为什么叫 nid?因为 id 是 Python 内置函数,虽然能用,但容易混淆。
nid = models.AutoField(primary_key=True)
# CharField 对应 SQL 的 VARCHAR。必须指定 max_length,因为数据库需要预分配空间。
# verbose_name 是 Django 后台管理界面显示的友好名称。
# null=True 表示数据库里这个字段可以是 NULL;blank=True 表示后台表单可以不填。
# 为什么两个都设?null 管数据库,blank 管表单验证,各司其职。
nick_name = models.CharField(max_length=16, verbose_name='昵称', null=True, blank=True)
# ForeignKey 是外键,建立"多对一"关系。
# to='Avatars' 指向头像表。to_field='nid' 指定关联到对方的哪个字段(默认是主键)。
# on_delete=models.SET_NULL 表示:如果头像被删了,用户表里的 avatar 字段设为 NULL,而不是删掉用户。
# 为什么不用 CASCADE(级联删除)?因为用户比头像重要,头像没了用户还在。
avatar = models.ForeignKey(
to='Avatars',
to_field='nid',
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name='用户头像'
)
# ManyToManyField 是"多对多"关系。
# 数据库层面,Django 会自动创建一张中间表:app01_userinfo_collects。
# 这张表只有三个字段:自增 ID、userinfo_id、articles_id。
collects = models.ManyToManyField(
to='Articles',
blank=True,
verbose_name='收藏的文章'
)
# Meta 是配置类,不是字段。
# verbose_name_plural 是 Django 后台里这个模型的复数名称(如"用户们")。
class Meta:
verbose_name_plural = '用户'
class Articles(models.Model):
nid = models.AutoField(primary_key=True)
title = models.CharField(max_length=32, verbose_name='标题')
# TextField 对应 SQL 的 TEXT,不限长度,适合存长文章。
content = models.TextField(verbose_name='内容')
# auto_now_add=True 表示"创建时自动设为当前时间",之后不再变。
# auto_now=True 表示"每次保存都自动更新为当前时间"。
# 为什么需要两个字段?create_date 用来显示发布时间,change_date 用来显示最后修改时间。
create_date = models.DateTimeField(auto_now_add=True, verbose_name='发布时间')
change_date = models.DateTimeField(auto_now=True, verbose_name='修改时间')
# BooleanField 对应 SQL 的 BOOL/TINYINT(1)。default=True 表示默认勾选推荐。
recommend = models.BooleanField(default=True, verbose_name='是否推荐')
# IntegerField 整数。default=0 防止 NULL 出现,NULL 做算术会出错。
look_count = models.IntegerField(default=0, verbose_name='阅读量')
class Comment(models.Model):
# ForeignKey 指向 Articles,on_delete=CASCADE。
# 为什么评论用级联删除?因为文章都没了,评论留着没意义。
article = models.ForeignKey(
'Articles',
on_delete=models.CASCADE,
verbose_name='评论文章'
)
# ForeignKey to='self' 是"自关联",用于实现楼中楼(嵌套评论)。
# null=True, blank=True 表示顶层评论没有父评论。
parent_comment = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name='父评论'
)
7.4 视图与路由
# app01/views.py
# render 是 Django 的快捷函数,它做三件事:
# 1. 找到模板文件(如 templates/student.html)
# 2. 把 Python 变量(字典)传给模板
# 3. 把模板渲染成 HTML 字符串,包装成 HttpResponse 返回。
from django.shortcuts import render
def student(request):
# request 参数是 WSGIRequest 对象,封装了 HTTP 请求的所有信息:
# request.method(GET/POST)、request.GET(查询参数)、request.POST(表单数据)等。
# 为什么每个视图都必须有 request?因为服务器要知道"谁在请求、请求什么"。
name = "张三"
age = 20
# 第三个参数是上下文字典(context)。模板里用 {{ name }} 就能拿到这个值。
# Django 模板语言(DTL)故意设计得很弱,不能执行任意 Python 代码,防止前端注入攻击。
return render(request, 'student.html', {'name': name, 'age': age})
# blog_jj/urls.py
# path() 函数定义路由规则。
# 第一个参数是 URL 路径(如 'student/')。
# 第二个参数是视图函数(如 views.student)。
# Django 收到请求后,从 urlpatterns 列表里从上到下匹配,匹配到就调用对应函数。
from django.urls import path
from app01 import views
urlpatterns = [
path('admin/', admin.site.urls),
path('student/', views.student), # 访问 http://127.0.0.1:8000/student/
path('', views.index), # 访问根路径
]
7.5 模板语言
<!-- templates/student.html -->
<!-- {{ name }} 是变量插值。Django 会自动把 name 转成字符串并做 HTML 转义。 -->
<!-- 为什么要转义?如果 name 是 "<script>alert('xss')</script>",直接插入会执行恶意脚本。 -->
<!-- Django 默认转义成 <script>...,变成纯文本显示。 -->
<p>姓名:{{ name }}</p>
<!-- {% %} 是模板标签,执行逻辑控制。 -->
{% if age >= 18 %}
<p>已成年</p>
{% else %}
<p>未成年</p>
{% endif %}
<!-- for 循环遍历列表 -->
{% for item in items %}
<li>{{ forloop.counter }}. {{ item }}</li> <!-- forloop.counter 是循环序号(从1开始) -->
{% empty %}
<p>没有数据</p> <!-- 如果 items 为空,显示这个 -->
{% endfor %}
新增章节:解决企业安全软件拦截 pip 安装(SSLKEYLOGFILE 问题)
问题现象
你在 PyCharm 里安装 requests 等第三方库时,可能会遇到这样的报错:
FileNotFoundError: [Errno 2] No such file or directory: 'C:\\ProgramData\\...\\sslkey.log'
根本原因: 你的电脑(通常是学校或企业机房)安装了 奇安信(QiAnXin)、天擎 或类似的安全监控软件。这些软件为了监控 HTTPS 流量,会在系统里设置一个环境变量 SSLKEYLOGFILE,让 Python 把 SSL 密钥日志写到指定文件。但这个路径可能不存在或没有权限访问,导致 pip/requests 初始化 SSL 时直接崩溃。
解决方案:在代码开头清除这个环境变量
⚠️ 重要注意事项
1. 顺序不能颠倒!
# ❌ 错误:先导入 requests,再清除环境变量——没用!
import requests # 此时已经读取了 SSLKEYLOGFILE,已经报错了
import os
del os.environ["SSLKEYLOGFILE"]
# ✅ 正确:先清除,再导入
# 在terminal输入
set SSLKEYLOGFILE=
pip3 install requests #这里以requests类为例
import requests # 现在 SSL 上下文初始化时看不到这个变量了
这个方案的原理图
正常流程:
requests 导入 → 初始化 urllib3 → 检查 SSLKEYLOGFILE → 不存在 → 正常继续
被安全软件破坏的流程:
requests 导入 → 初始化 urllib3 → 检查 SSLKEYLOGFILE → 存在,值指向 C:\ProgramData\...\sslkey.log
↓
尝试打开这个文件写入密钥日志
↓
文件不存在 / 没有权限 → FileNotFoundError → 崩溃
我们的修复流程:
清除 SSLKEYLOGFILE → requests 导入 → 检查 SSLKEYLOGFILE → 不存在 → 正常继续 ✓
总结
| 场景 | 做法 |
|---|---|
| 单个文件临时用 | 文件开头加 import os + del os.environ["SSLKEYLOGFILE"] + import requests |
| 多个文件复用 | 封装成 fix_ssl.py 模块,每个文件导入调用 |
| 彻底根治 | 联系管理员卸载/配置安全软件,或在自己的电脑上禁用相关监控 |
这个方案的核心思想就是:在 requests 还没初始化之前,抢先一步把"陷阱"拆掉。
附录:常见报错与底层原因
| 报错信息 | 根本原因 | 解决办法 |
|---|---|---|
IndentationError |
Python 用缩进表示代码块,你混用了 Tab 和空格 | 统一用 4 个空格 |
TypeError: can only concatenate str (not "int") to str |
Python 是强类型,"a" + 1 不允许 |
用 str(1) 或 f-string |
UnicodeDecodeError |
bytes 解码成 str 时编码不对 | 确认文件/网页编码,用 decode('utf-8') 或 gbk |
Address already in use |
上次运行的服务器没关,端口还被占用 | 换端口,或杀掉旧进程 |
ModuleNotFoundError |
Python 找不到模块,可能没安装或不在 sys.path | pip install 或调整 PYTHONPATH |
Migration schema missing |
改了模型但没生成/执行迁移 | makemigrations + migrate |
Too many open files |
socket 没 close,文件描述符耗尽 | 检查所有 close() 和 with 语句 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)