返回 Python
Python
17 分钟阅读

Python 爬虫

「先观察页面规律,再写选择器」的思路

Web 爬虫的本质,是针对网络中对外公开呈现的内容,梳理其 HTTP 请求链路、DOM 结构与数据展示规律,进而按规则解析并提取目标字段的技术手段。

场景一:信息流分析

  • 以小红书首页为例,内容以列表形式渲染,每个卡片对应一条笔记。
  • 通过爬虫批量提取标题(title)、封面图 URL(cover image)等字段,可进一步做统计分析、特征工程,或反推平台推荐逻辑与用户画像。

场景二:无 API 的数据聚合

  • 部分站点 UI 体验欠佳,但数据具有不可替代性,且未开放公开 API,无法通过正规接口拉取。
  • 此时可在合规前提下,抓取前端页面中结构稳定、规律清晰的数据,落盘至本地(文件、SQLite 等)。
  • 后续可接入自研 Web 项目:Python + Jinja2 服务端渲染,或 Spring Boot 封装 RESTful API 再对接 Vue/React 前端——技术栈可按需选型。
  • 配合自定义展示层,能显著提升数据浏览与分析效率。

下文讨论的,正是这类场景下的爬虫实现思路。

Python 爬虫工作流:观察页面规律 → requests 发起请求 → BeautifulSoup 解析选择器 → 提取结构化数据 → 本地存储


为什么用 Python

  • 爬虫程序通过自动化方式模拟客户端访问 Web / 移动端资源,再按预设规则提取目标内容。更前沿的方案会结合深度学习做自适应解析,本文暂不展开。
  • 从技术实现看,Java、JavaScript、Go 等语言均具备 HTTP 客户端与 HTML 解析能力。
  • 实际差异在于生态:Python 拥有 Beautiful SoupScrapyaiohttp 等成熟库,封装了请求、解析、管道等常见环节,开发成本较低。
  • 语法层面同样占优:同等功能,Java 往往需要更多样板代码;而 Python 调用第三方库通常数行即可。爬虫任务多为脚本级需求,无需庞大服务集群,一段 50 行以内的代码往往就能跑通「请求 → 解析 → 落盘」全流程。
  • 综合生态与效率,Python 是业界爬虫开发的主流选择之一。
  • 个人技术栈方面,Python 是我的主力脚本语言(详见 Profile),因此本文示例均以 Python 实现。

Python 爬虫经常使用的几个核心工具

工具作用适用场景
requests发起 HTTP/HTTPS 请求静态网页、REST 接口
BeautifulSoup4解析 HTML/XML,CSS 选择器提取节点静态页面结构化解析
lxml高性能 HTML/XML 解析后端复杂页面、大规模解析
Scrapy爬虫框架(请求 / 解析 / 管道一体化)大规模、可扩展爬取
aiohttp异步 HTTP 客户端高并发 I/O 密集型任务
Selenium/Playwright驱动真实浏览器JS 动态渲染、需登录交互的页面

基础流程:requests 发起 HTTP 请求 → 获取响应体 → BeautifulSoup 解析 → 提取字段 → 落盘。

请求阶段需注意服务端校验:缺少 CookieContent-TypeAuthorization 等 Header 时,可能返回 4xx 错误。建议在 Chrome / Edge 中打开目标页,F12 → Network 面板查看成功请求的 Header,再在 Python 中复现。

响应体类型决定解析方式:

  • JSON(RESTful API):response.json() 按字段路径提取;前端 SPA 与移动端(如 Retrofit2)通常走这条链路。
  • HTML(服务端渲染页面):分析 DOM 结构,用 CSS 选择器定位重复节点,提取核心字段后持久化存储。

实际案例

  • 尤雨溪曾回应框架之争时提到:Pornhub 的前端技术栈是 Vue。这引发了我对部分站点前端实现的关注。
  • 顺着这条线索,我查阅了若干同类站点的页面结构与渲染方式。
  • 结论是:不少站点前端实现较为简单,URL 规律与 DOM 结构清晰,适合用 requests + BeautifulSoup 做自动化采集,再接入自定义展示层。

注意:实操此案例需能访问目标站点,网络环境可参考 路由器翻墙方案(Shadowsocks、V2RayNG、ClashX 等)。

目标站点:JavBus

  • 警告⚠️:请勿在公开场合打开上述链接。

其 URL 构造规律如下:

link = "https://www.javbus.com/KBI-001"
# 规律如下
link = host_name + "/" + series + "-" + code
  • 域名 + "/" + 系列番号 + "-" + 编号,例如 KBI-001

  • 封面图(Cover Image)对应 DOM 节点:

<div class="col-md-9 screencap">
    <a class="bigImage" href="/pics/cover/6qah_b.jpg">
      <img src="/pics/cover/6qah_b.jpg" title="KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ">
    </a>
</div>
  • 样品图(Sample Images)对应 #sample-waterfall 容器,子节点为 a.sample-box
<div id="sample-waterfall">
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-1.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_1.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 1">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-2.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_2.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 2">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-3.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_3.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 3">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-4.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_4.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 4">
    </div>
  </a>       
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-5.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_5.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 5">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-6.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_6.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 6">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-7.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_7.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 7">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-8.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_8.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 8">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-9.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_9.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 9">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-10.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_10.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 10">
    </div>
  </a>       
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-11.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_11.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 11">
    </div>
  </a>        
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-12.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_12.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 12">
    </div>
  </a>       
  <a class="sample-box" href="https://pics.dmm.co.jp/digital/video/118kbi00002/118kbi00002jp-13.jpg">
    <div class="photo-frame">
      <img src="/pics/sample/6qah_13.jpg" title="KBI-002 KANBi専属出演第1弾!旦那を忘れる程 汗だく汁だくで絡み合う 濃厚接吻性交3本番 舌を絡ませ抱き合いながら絶頂に達する密着性交! 織笠るみ - 樣品圖像 - 13">
    </div>
  </a>            
</div>

页面为静态 HTML,无需 Selenium/Playwright 等浏览器驱动,requests + BeautifulSoup 即可完成解析。

实现代码如下。

配置文件 series_image_cover_config.py

series = "KBI"
start_code = 1
end_code = 100

主程序 series_image_cover_download.py(解析 + 异步下载 + 入口)

import os
import asyncio
import aiohttp
from bs4 import BeautifulSoup
import urllib3
 
from series_image_cover_config import *
 
# 禁用 InsecureRequestWarning 警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
 
host = "https://www.javbus.com"
 
HEADERS = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9",
}
 
async def download_image_cover(session: aiohttp.ClientSession, name: str, series: str):
    URL = f"{host}/{name}"
    SAVE_DIR = f"./{series}"
    os.makedirs(SAVE_DIR, exist_ok=True)
 
    try:
        async with session.get(URL, timeout=aiohttp.ClientTimeout(total=10), ssl=False) as response:
            if response.status != 200:
                print(f"[{name}] 页面不存在,跳过")
                return
        
            html = await response.text()
            soup = BeautifulSoup(html, "html.parser")
 
            title = soup.find("title")
            cover_tag = soup.select_one("a.bigImage img")
 
            if not cover_tag:
                print(f"[{name}] 无封面,跳过")
                return
 
            cover_url = cover_tag["src"]
            if cover_url.startswith("/"):
                cover_url = host + cover_url
 
            ext = os.path.splitext(cover_url)[1]
            cover_path = os.path.join(SAVE_DIR, f"{name}{ext}")
 
            async with session.get(
                cover_url, 
                headers={"Referer": URL, **HEADERS}, 
                ssl=False
            ) as img_response:
                if img_response.status == 200:
                    with open(cover_path, "wb") as f:
                        f.write(await img_response.read())
                    
                    print(f"[{name}] 下载成功 → {cover_path}")
                    print(f"[{name}] 标题:{title.text.strip() if title else '无标题'}")
                else:
                    print(f"[{name}] 图片下载失败,状态码:{img_response.status}")
 
    except asyncio.TimeoutError:
        print(f"[{name}] 请求超时,跳过")
    except Exception as e:
        print(f"[{name}] 发生错误:{e}")
        return
 
async def start_to_download():
    names = [
        f"{SERIES}-{code:03d}" for code in range(max(1, START_CODE), END_CODE)
    ]
    
    connector = aiohttp.TCPConnector(limit=10)
    async with aiohttp.ClientSession(
        connector=connector,
        headers=HEADERS
    ) as session:
 
        tasks = [download_image_cover(session, name, SERIES) for name in names]
        
        batch_size = 10
        for i in range(0, len(tasks), batch_size):
            batch = tasks[i:i+batch_size]
            await asyncio.gather(*batch)
            await asyncio.sleep(0.5)  # 防止请求太快,被他们知道这是来自脚本的下载解析行为
 
if __name__ == "__main__":
    asyncio.run(start_to_download())

运行:

python ./series_image_cover_download.py

上述脚本按系列番号批量遍历:给定 series(如 KBI)与 start_code / end_code 范围,自动生成 KBI-001KBI-002 … 等 URL 并下载封面。

番号可类比产品型号体系:厂商(品牌)→ 系列(番号,如 KBI)→ 具体编号(如 KBI-002)。三星的 Galaxy Z-Fold / Z-Flip / S 系列即是类似的分层结构。

若需下载单片的全部样品图,解析 #sample-waterfall 区域即可,代码如下。

配置文件 special_image_detail_config.py

videos = [
  {
    "name" : "KBI-001",
  },
  {
    "name": "KBI-002",
  }
]

主程序 special_image_detail_download.py

import os
import requests
from bs4 import BeautifulSoup
 
host = "https://www.javbus.com"
 
def image_download(names, onlyFolderImage = False):
    for name in names:
        URL = host + "/" + name
        SAVE_DIR = "./{}".format(name)
 
        HEADERS = {
            "authority": "www.javbus.com",
            "method": "GET",
            "path": f"/{name}",
            "scheme": "https",
            "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
            "accept-encoding": "gzip, deflate, br, zstd",
            "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
            "cookie": "PHPSESSID=nnelcojatbmsjh2s02i4m0fno4; existmag=mag; _tea_utm_cache_10000007=undefined",
            "dnt": "1",
            "priority": "u=0, i",
            "sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Microsoft Edge";v="140"',
            "sec-ch-ua-mobile": "?0",
            "sec-ch-ua-platform": '"macOS"',
            "sec-fetch-dest": "document",
            "sec-fetch-mode": "navigate",
            "sec-fetch-site": "none",
            "sec-fetch-user": "?1",
            "upgrade-insecure-requests": "1",
            "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) "
                        "Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
        }
 
        os.makedirs(SAVE_DIR, exist_ok=True)
 
        session = requests.Session()
        response = session.get(URL, headers=HEADERS)
        soup = BeautifulSoup(response.text, "html.parser")
 
        title_tag = soup.find("title")
        title = title_tag.text.strip() if title_tag else "unknown_title"
        print("页面标题:", title)
 
        cover_tag = soup.select_one("a.bigImage img")
        if cover_tag:
            cover_url = cover_tag["src"]
            if cover_url.startswith("/"):
                cover_url = host + cover_url
            ext = os.path.splitext(cover_url)[1]
            cover_path = os.path.join(SAVE_DIR, f"cover{ext}")
            cover_path = cover_path.replace("cover.jpg", "folder.jpg")
            
            cover_headers = HEADERS.copy()
            cover_headers["referer"] = URL
            
            r = session.get(cover_url, headers=cover_headers, stream=True)
            with open(cover_path, "wb") as f:
                for chunk in r.iter_content(1024):
                    f.write(chunk)
            print("封面图已下载:", cover_path)
 
        if not onlyFolderImage:
            sample_tags = soup.select("a.sample-box")
            if not sample_tags:
                print("没有样本图")
            else:
                for idx, a_tag in enumerate(sample_tags, 1):
                    img_url = a_tag.get("href")
                    if not img_url:
                        continue
                    ext = os.path.splitext(img_url)[1]
                    img_path = os.path.join(SAVE_DIR, f"sample_{idx}{ext}")
                    r = session.get(img_url, headers=HEADERS, stream=True)
                    with open(img_path, "wb") as f:
                        for chunk in r.iter_content(1024):
                            f.write(chunk)
                    print(f"样本图 {idx} 已下载:", img_path) 
 
if __name__ == "__main__":
    from special_image_detail_config import videos
    
    image_download([video["name"] for video in videos])

运行:

python ./special_image_detail_download.py

视频资源下载

JavBus 等站点通常提供磁力链接,可用 Motrix 等客户端拉取;但对部分资源,直连 P2P 速度可能不理想。常见替代方案:

  1. 网盘离线:夸克、迅雷会员版等,将磁力转存后下载至本地。
  2. M3U8 流媒体:部分聚合平台以 HLS 协议输出,可借助自研脚本抓取分片并合并,详见 M3U8 下载

更多玩法

  • 若具备基础前端能力,无需 React/Vue,仅用 HTML + CSS + JavaScript 即可将爬取结果做成瀑布流图库: img.png

基于 Python Flask + HTML/CSS/JS 实现。

后续将核心逻辑迁移至 Go + Gin + Bootstrap,在 Web 层以表格驱动抓取,并新增图库展示页(Vibe Coding 方式迭代)。 img2.png img3.png

说明

  1. 本文仅讨论页面结构与爬虫技术实现,案例站点内容仅供学习参考,不构成任何商业用途或价值导向声明。本人不对案例站点内容负责。技术是无罪的
  2. 移动端扩展:Android 逆向与 HTTPS 抓包(Charles / mitmproxy)是另一条常见路线,可定位 App 内 XHR 接口,往往比解析 HTML 更稳定。竞品分析时,可复现数据对比,用可验证的结果替代主观争论。
  3. 其他方向
    • 3.1 采集公众号、小红书等平台的公开图文,落盘至 SQLite / TXT 等本地存储。
    • 3.2 将归档内容作为语料,接入 RAG 或微调流程,用于检索增强与信息匹配。

相关文章

Python
公开
11 分钟
M3U8 Python 下载脚本
流媒体场景:M3U8 分片下载与合并。