Python数据采集实战:从零构建稳定可靠的数据搬运系统
在实际项目开发中我们经常需要从外部站点获取数据并整合到自己的系统中。这类“数据搬运”任务看似简单但实际操作时会遇到编码处理、网络请求、数据解析、反爬机制、数据存储等多个技术难点。本文将以一个典型的数据搬运场景为例详细介绍从零开始构建一个稳定可靠的数据采集方案。1. 理解数据搬运的技术挑战数据搬运不仅仅是简单的复制粘贴而是涉及完整的数据生命周期管理。在实际工程中我们需要考虑以下几个核心问题1.1 源站点的访问限制大多数网站都有反爬虫机制包括但不限于请求频率限制、User-Agent检测、IP封禁、验证码挑战等。直接使用简单请求很容易被识别为爬虫行为。1.2 数据解析的复杂性不同网站的数据结构差异很大HTML结构可能嵌套复杂、包含大量无关标签或者使用JavaScript动态加载数据。静态解析往往无法获取完整内容。1.3 数据一致性和完整性搬运过程中需要确保数据不丢失、不重复特别是当源站数据更新时如何识别新增、修改和删除的内容。1.4 法律和道德边界数据搬运必须遵守robots.txt协议尊重版权声明避免对源站造成过大访问压力。商业用途需要获得明确授权。2. 环境准备与工具选型构建数据搬运系统需要选择合适的工具链。以下是推荐的技术栈配置2.1 开发环境要求Python 3.8数据处理的黄金标准请求库requests requests_html处理动态内容解析库BeautifulSoup4 lxmlHTML解析数据存储SQLite开发测试/ PostgreSQL生产环境任务调度APScheduler或Celery定时任务2.2 项目依赖配置创建requirements.txt文件明确版本依赖requests2.31.0 requests-html0.10.0 beautifulsoup44.12.2 lxml4.9.3 apscheduler3.10.4 sqlalchemy2.0.23 python-dotenv1.0.0安装依赖pip install -r requirements.txt2.3 项目结构设计规范的项目结构有助于后续维护data_crawler/ ├── config/ │ ├── __init__.py │ └── settings.py ├── spiders/ │ ├── __init__.py │ └── base_spider.py ├── models/ │ ├── __init__.py │ └── data_models.py ├── utils/ │ ├── __init__.py │ ├── logger.py │ └── request_utils.py ├── storage/ │ ├── __init__.py │ └── database.py └── main.py3. 构建稳健的请求处理机制网络请求是数据搬运的基础必须处理好异常情况和反爬策略。3.1 请求会话管理创建可复用的请求会话保持连接池和Cookie持久化import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import time import random class RequestSession: def __init__(self): self.session requests.Session() # 设置重试策略 retry_strategy Retry( total3, backoff_factor1, status_forcelist[429, 500, 502, 503, 504], ) adapter HTTPAdapter(max_retriesretry_strategy) self.session.mount(http://, adapter) self.session.mount(https://, adapter) # 设置合理的请求头 self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.8,en-US;q0.5,en;q0.3, Accept-Encoding: gzip, deflate, Connection: keep-alive, Upgrade-Insecure-Requests: 1, }) def get_with_retry(self, url, delay2, max_delay10): 带延迟的GET请求避免请求过快 time.sleep(random.uniform(delay, delay 1)) try: response self.session.get(url, timeout30) response.raise_for_status() return response except requests.exceptions.RequestException as e: print(f请求失败: {e}) if delay max_delay: time.sleep(delay) return self.get_with_retry(url, delay * 2, max_delay) raise3.2 动态内容处理对于JavaScript渲染的页面需要使用requests-htmlfrom requests_html import HTMLSession class DynamicContentHandler: def __init__(self): self.session HTMLSession() def get_dynamic_content(self, url, wait2): 获取JavaScript渲染后的内容 try: response self.session.get(url) # 执行JavaScript并等待页面加载 response.html.render(sleepwait, timeout20) return response.html except Exception as e: print(f动态内容获取失败: {e}) return None4. 数据解析与清洗策略获取HTML内容后需要准确提取目标数据并进行清洗。4.1 使用BeautifulSoup进行解析创建通用的解析工具类from bs4 import BeautifulSoup import re class DataParser: staticmethod def parse_html(html_content, parserlxml): 解析HTML内容 return BeautifulSoup(html_content, parser) staticmethod def extract_text(element, default): 安全提取文本内容 if element: text element.get_text(stripTrue) return text if text else default return default staticmethod def extract_attribute(element, attr, default): 安全提取属性值 if element and element.has_attr(attr): return element[attr] return default staticmethod def clean_text(text): 清洗文本数据 if not text: return # 移除多余空白字符 text re.sub(r\s, , text) # 移除不可见字符 text re.sub(r[\x00-\x1f\x7f-\x9f], , text) return text.strip()4.2 特定网站解析器实现针对具体网站结构实现定制解析器class TargetSiteParser(DataParser): def parse_article_list(self, html_content): 解析文章列表页 soup self.parse_html(html_content) articles [] # 根据实际网站结构调整选择器 article_items soup.select(.article-list .item) for item in article_items: title_elem item.select_one(.title a) date_elem item.select_one(.date) summary_elem item.select_one(.summary) article { title: self.clean_text(self.extract_text(title_elem)), url: self.extract_attribute(title_elem, href), publish_date: self.clean_text(self.extract_text(date_elem)), summary: self.clean_text(self.extract_text(summary_elem)), source: target_site } if article[title] and article[url]: articles.append(article) return articles def parse_article_detail(self, html_content): 解析文章详情页 soup self.parse_html(html_content) content_elem soup.select_one(.article-content) author_elem soup.select_one(.author) detail { content: self.clean_text(self.extract_text(content_elem)), author: self.clean_text(self.extract_text(author_elem)), images: [img[src] for img in content_elem.select(img)] if content_elem else [] } return detail5. 数据存储与去重机制搬运的数据需要持久化存储并确保不重复采集。5.1 数据库模型设计使用SQLAlchemy定义数据模型from sqlalchemy import create_engine, Column, String, Text, DateTime, Boolean from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime import hashlib Base declarative_base() class Article(Base): __tablename__ articles id Column(String(32), primary_keyTrue) title Column(String(500), nullableFalse) url Column(String(1000), nullableFalse, uniqueTrue) content Column(Text) summary Column(Text) author Column(String(200)) publish_date Column(DateTime) source Column(String(100)) created_at Column(DateTime, defaultdatetime.utcnow) updated_at Column(DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) is_processed Column(Boolean, defaultFalse) classmethod def generate_id(cls, url): 根据URL生成唯一ID return hashlib.md5(url.encode()).hexdigest()5.2 数据存储管理器实现数据存储和去重逻辑class DataStorageManager: def __init__(self, database_urlsqlite:///articles.db): self.engine create_engine(database_url) Base.metadata.create_all(self.engine) Session sessionmaker(bindself.engine) self.session Session() def article_exists(self, url): 检查文章是否已存在 article_id Article.generate_id(url) return self.session.query(Article).filter_by(idarticle_id).first() is not None def save_article(self, article_data): 保存文章数据 if self.article_exists(article_data[url]): print(f文章已存在: {article_data[title]}) return False article Article( idArticle.generate_id(article_data[url]), titlearticle_data[title], urlarticle_data[url], contentarticle_data.get(content, ), summaryarticle_data.get(summary, ), authorarticle_data.get(author, ), publish_datearticle_data.get(publish_date), sourcearticle_data.get(source, unknown) ) try: self.session.add(article) self.session.commit() print(f成功保存文章: {article_data[title]}) return True except Exception as e: self.session.rollback() print(f保存文章失败: {e}) return False def close(self): 关闭数据库连接 self.session.close()6. 完整的数据搬运流程实现将各个模块组合成完整的工作流import logging from urllib.parse import urljoin class DataCrawler: def __init__(self, base_url, storage_manager): self.base_url base_url self.request_session RequestSession() self.parser TargetSiteParser() self.storage storage_manager self.logger self._setup_logger() def _setup_logger(self): 配置日志 logger logging.getLogger(DataCrawler) logger.setLevel(logging.INFO) if not logger.handlers: handler logging.StreamHandler() formatter logging.Formatter( %(asctime)s - %(name)s - %(levelname)s - %(message)s ) handler.setFormatter(formatter) logger.addHandler(handler) return logger def crawl_article_list(self, list_url, pages5): 爬取文章列表 all_articles [] for page in range(1, pages 1): self.logger.info(f正在爬取第 {page} 页) # 构造分页URL根据实际网站结构调整 page_url f{list_url}?page{page} try: response self.request_session.get_with_retry(page_url) articles self.parser.parse_article_list(response.text) all_articles.extend(articles) self.logger.info(f第 {page} 页获取到 {len(articles)} 篇文章) except Exception as e: self.logger.error(f爬取第 {page} 页失败: {e}) continue return all_articles def crawl_article_detail(self, article_info): 爬取文章详情 try: response self.request_session.get_with_retry(article_info[url]) detail self.parser.parse_article_detail(response.text) # 合并文章信息 complete_article {**article_info, **detail} return complete_article except Exception as e: self.logger.error(f爬取文章详情失败: {e}) return None def run(self, list_url, max_articles50): 运行完整的爬虫流程 self.logger.info(开始数据搬运任务) # 获取文章列表 articles self.crawl_article_list(list_url) self.logger.info(f总共获取到 {len(articles)} 篇文章列表) # 限制处理数量 articles articles[:max_articles] success_count 0 for i, article_info in enumerate(articles, 1): self.logger.info(f处理第 {i}/{len(articles)} 篇文章: {article_info[title]}) # 检查是否已存在 if self.storage.article_exists(article_info[url]): self.logger.info(文章已存在跳过) continue # 获取详情内容 complete_article self.crawl_article_detail(article_info) if not complete_article: continue # 保存到数据库 if self.storage.save_article(complete_article): success_count 1 # 避免请求过快 import time time.sleep(1) self.logger.info(f数据搬运完成成功保存 {success_count} 篇文章) return success_count7. 配置管理与错误处理7.1 配置文件设计创建config/settings.pyimport os from dotenv import load_dotenv load_dotenv() class Config: # 数据库配置 DATABASE_URL os.getenv(DATABASE_URL, sqlite:///articles.db) # 请求配置 REQUEST_DELAY float(os.getenv(REQUEST_DELAY, 2)) MAX_RETRIES int(os.getenv(MAX_RETRIES, 3)) # 目标网站配置 TARGET_BASE_URL os.getenv(TARGET_BASE_URL, https://example.com) LIST_URL os.getenv(LIST_URL, /articles) # 爬取限制 MAX_ARTICLES int(os.getenv(MAX_ARTICLES, 50)) MAX_PAGES int(os.getenv(MAX_PAGES, 5)) # 日志配置 LOG_LEVEL os.getenv(LOG_LEVEL, INFO)7.2 主程序入口创建main.pyfrom config.settings import Config from storage.database import DataStorageManager from spiders.data_crawler import DataCrawler def main(): # 初始化组件 storage DataStorageManager(Config.DATABASE_URL) crawler DataCrawler(Config.TARGET_BASE_URL, storage) try: # 构建完整URL list_url Config.TARGET_BASE_URL Config.LIST_URL # 运行爬虫 success_count crawler.run( list_urllist_url, max_articlesConfig.MAX_ARTICLES ) print(f任务完成成功搬运 {success_count} 篇文章) except Exception as e: print(f任务执行失败: {e}) finally: storage.close() if __name__ __main__: main()8. 常见问题排查与解决方案在实际运行中可能会遇到各种问题以下是典型问题及解决方法8.1 请求被拒绝或返回403错误现象请求返回403状态码或收到Access Denied响应。可能原因User-Agent被识别为爬虫请求频率过高触发反爬IP地址被暂时封禁需要处理Cookie或Session解决方案# 1. 轮换User-Agent user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ] # 2. 增加请求间隔 time.sleep(random.uniform(3, 7)) # 3. 使用代理IP需谨慎确保合法使用 # proxies {http: http://proxy.example.com:8080}8.2 数据解析失败或提取为空现象能获取HTML但解析不到目标数据。可能原因HTML结构发生变化选择器表达式错误数据通过JavaScript动态加载解决方案# 1. 打印HTML结构确认变化 print(soup.prettify()[:1000]) # 只打印前1000字符 # 2. 使用更宽松的选择器 # 原选择器.article-list .item # 备用选择器[class*article] li, .item, .list-item # 3. 检查动态内容 dynamic_handler DynamicContentHandler() html dynamic_handler.get_dynamic_content(url)8.3 数据库连接或存储异常现象程序在保存数据时崩溃或报错。可能原因数据库连接超时数据长度超出字段限制唯一约束冲突解决方案# 1. 增加字段长度检查 def validate_field_length(data, field, max_length): if field in data and data[field]: if len(data[field]) max_length: data[field] data[field][:max_length-3] ... return data # 2. 使用事务确保数据一致性 try: with self.session.begin(): self.session.add(article) # 其他数据库操作 except Exception as e: self.logger.error(f数据库操作失败: {e}) self.session.rollback()9. 生产环境部署建议将数据搬运系统部署到生产环境需要考虑更多因素9.1 配置环境变量创建.env文件管理敏感配置DATABASE_URLpostgresql://user:passwordlocalhost:5432/crawler_db REQUEST_DELAY3 MAX_ARTICLES100 LOG_LEVELINFO9.2 添加监控和告警# 监控关键指标 class CrawlerMonitor: def __init__(self): self.metrics { total_requests: 0, successful_requests: 0, failed_requests: 0, articles_saved: 0 } def record_request(self, successTrue): self.metrics[total_requests] 1 if success: self.metrics[successful_requests] 1 else: self.metrics[failed_requests] 1 def get_success_rate(self): if self.metrics[total_requests] 0: return 0 return self.metrics[successful_requests] / self.metrics[total_requests]9.3 设置定时任务使用APScheduler实现定时执行from apscheduler.schedulers.blocking import BlockingScheduler def scheduled_crawl(): 定时爬取任务 storage DataStorageManager(Config.DATABASE_URL) crawler DataCrawler(Config.TARGET_BASE_URL, storage) try: crawler.run(Config.TARGET_BASE_URL Config.LIST_URL) finally: storage.close() # 每天凌晨2点执行 scheduler BlockingScheduler() scheduler.add_job(scheduled_crawl, cron, hour2) scheduler.start()10. 法律合规与最佳实践数据搬运必须遵守相关法律法规和道德准则10.1 尊重robots.txt在爬取前检查目标网站的robots.txtimport urllib.robotparser rp urllib.robotparser.RobotFileParser() rp.set_url(f{Config.TARGET_BASE_URL}/robots.txt) rp.read() if not rp.can_fetch(*, Config.TARGET_BASE_URL Config.LIST_URL): print(根据robots.txt协议不允许爬取该页面) exit(1)10.2 控制访问频率避免对目标网站造成压力单次任务间隔至少2-3秒每天总请求量控制在合理范围避开网站高峰时段10.3 数据使用限制明确标注数据来源不用于商业用途除非获得授权定期清理或更新数据尊重版权和隐私政策数据搬运是一个需要综合考虑技术实现、系统稳定性和法律合规的复杂任务。通过本文介绍的完整方案你可以构建一个健壮的数据采集系统但务必在实际使用中持续优化和调整确保既满足业务需求又遵守相关规范。

相关新闻

NPN与PNP三极管核心区别与电路设计实战指南

NPN与PNP三极管核心区别与电路设计实战指南

1. 从一次调试“乌龙”说起:为什么必须搞懂NPN和PNP?前几天,一个刚入行的硬件同事找我,说他的一个开关控制电路怎么调都不对。电路很简单,用一个微控制器的IO口去控制一个继电器,他按照网上找到的一个“经典…

2026/10/20 19:41:22 阅读更多 →
ARM CMSIS-DSP库基础函数深度解析:从绝对值、加法、乘法到点积的性能优化实践

ARM CMSIS-DSP库基础函数深度解析:从绝对值、加法、乘法到点积的性能优化实践

1. 项目概述:为什么需要关注ARM DSP库的基础函数? 如果你正在基于ARM Cortex-M系列或者Cortex-A系列的内核做嵌入式开发,尤其是涉及信号处理、电机控制、音频算法这些对实时性和计算效率有要求的领域,那么ARM官方提供的CMSIS-DSP库…

2026/10/18 11:47:10 阅读更多 →
解决Python pip安装中的Invalid wheel filename错误

解决Python pip安装中的Invalid wheel filename错误

1. 问题现象与背景解析 最近在Python开发环境中执行 pip install 命令时,不少开发者遇到了一个看似简单却令人困惑的错误提示:"Invalid wheel filename xxx.wh1"(文件名不合法)。这个报错通常发生在尝试安装第三方包时…

2026/10/20 2:49:32 阅读更多 →

最新新闻

Makefile自动化构建实战:从核心语法到工程实践

Makefile自动化构建实战:从核心语法到工程实践

1. 从一段真实经历说起:为什么你需要make老手可能早就离不开make了,但如果你是刚接触Linux开发的新手,或者一直用小项目、单文件代码“裸编”过来的,那我建议你认真看完这篇文章。我当年第一次接手一个稍微像样一点的C项目&#x…

2026/10/20 19:42:22 阅读更多 →
用Baostock一键批量下载A股K线数据到本地:量化回测的自动化数据方案

用Baostock一键批量下载A股K线数据到本地:量化回测的自动化数据方案

简介:面向金融数据分析与量化研究场景,这份工具包基于Baostock金融数据接口,实现股票与指数K线数据的自动化下载与本地存储。资源覆盖上证指数、深证成指、沪深300、创业板指等主要市场指数,以及A股全部上市股票的日K线与5分钟高频…

2026/10/20 19:42:22 阅读更多 →
C++基础入门教程(一):基础知识大杂烩

C++基础入门教程(一):基础知识大杂烩

前言 这是一篇面向初学者的 C 入门梳理。标题叫"大杂烩",确实是因为入门阶段需要同时建立好几条线索:程序怎么从源码变成可执行文件、变量和类型是怎么回事、控制流和函数怎么写、类为什么存在、标准库能帮我们省掉哪些重复劳动。这些内容单独…

2026/10/20 19:42:22 阅读更多 →
通达信DLL公式实战:用C语言实现MA均线指标

通达信DLL公式实战:用C语言实现MA均线指标

简介:通达信插件开发资料包,面向具有C语言与Windows编程基础、希望扩展通达信分析功能的开发者。资源围绕通达信C接口、MA移动平均线算法与DLL公式展开,展示了如何把均线计算逻辑封装为动态库函数,并通过通达信外部接口集成到行情…

2026/10/20 19:42:22 阅读更多 →
Docker微服务架构实战:拆分、容器网络与治理演进

Docker微服务架构实战:拆分、容器网络与治理演进

简介:这是一份面向开发工程师、运维工程师及架构师的Docker与微服务架构落地实战PDF书籍,适合正在规划或实施微服务改造的中小型技术团队阅读。全书聚焦微服务技术选型、服务拆分原则、传统应用向微服务过渡的方法论,并系统讲解Docker技术原理…

2026/10/20 19:42:22 阅读更多 →
Django生产级博客骨架源码:零配置启动+可部署结构

Django生产级博客骨架源码:零配置启动+可部署结构

简介:这是一套基于Django框架从零开始构建的个人博客网站完整源码,面向Python后端初学者与Web开发入门者,帮助学习者系统掌握Django项目结构、ORM建模、模板渲染、静态资源管理及基础部署流程。压缩包共421个文件,涵盖26个核心Pyt…

2026/10/20 19:41:21 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →