增量式网络爬虫通用模板

news2025/6/8 7:05:39

之前做过一个项目,他要求是只爬取新产生的或者已经更新的页面,避免重复爬取未变化的页面,从而节省资源和时间。这里我需要设计一个增量式网络爬虫的通用模板。可以继承该类并重写部分方法以实现特定的解析和数据处理逻辑。这样可以更好的节约时间。

在这里插入图片描述

以下就是我熬几个通宵写的一个Python实现的增量式网络爬虫通用模板,使用SQLite数据库存储爬取状态,实现URL去重、增量更新和断点续爬功能。

import sqlite3
import hashlib
import time
import requests
from urllib.parse import urlparse, urljoin
from bs4 import BeautifulSoup
import re
import os
import logging
from queue import Queue
from threading import Thread, Lock

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler("incremental_crawler.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

class IncrementalCrawler:
    def __init__(self, db_path='crawler.db', max_threads=5, max_depth=3, 
                 politeness_delay=1.0, user_agent=None):
        """
        增量式网络爬虫初始化
        
        参数:
            db_path: 数据库文件路径
            max_threads: 最大线程数
            max_depth: 最大爬取深度
            politeness_delay: 请求延迟时间(秒)
            user_agent: 自定义User-Agent
        """
        self.db_path = db_path
        self.max_threads = max_threads
        self.max_depth = max_depth
        self.politeness_delay = politeness_delay
        self.user_agent = user_agent or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
        
        # 初始化数据库
        self._init_database()
        
        # 线程安全锁
        self.lock = Lock()
        
        # 请求会话
        self.session = requests.Session()
        self.session.headers.update({"User-Agent": self.user_agent})
        
        # 爬取队列
        self.queue = Queue()
        
        # 统计信息
        self.stats = {
            'total_crawled': 0,
            'total_links_found': 0,
            'start_time': time.time(),
            'last_crawl_time': 0
        }
    
    def _init_database(self):
        """初始化数据库结构"""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            
            # 创建URL表
            cursor.execute('''
            CREATE TABLE IF NOT EXISTS urls (
                id INTEGER PRIMARY KEY,
                url TEXT UNIQUE NOT NULL,
                depth INTEGER DEFAULT 0,
                status TEXT DEFAULT 'pending',
                content_hash TEXT,
                last_crawled REAL,
                created_at REAL DEFAULT (datetime('now'))
            )
            ''')
            
            # 创建域延迟表
            cursor.execute('''
            CREATE TABLE IF NOT EXISTS domain_delays (
                domain TEXT PRIMARY KEY,
                last_request REAL DEFAULT 0
            )
            ''')
            
            conn.commit()
    
    def add_seed_urls(self, urls, depth=0):
        """添加种子URL到队列和数据库"""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            for url in urls:
                # 规范化URL
                normalized_url = self._normalize_url(url)
                if not normalized_url:
                    continue
                    
                # 检查URL是否已存在
                cursor.execute("SELECT 1 FROM urls WHERE url = ?", (normalized_url,))
                if cursor.fetchone():
                    continue
                
                # 插入新URL
                try:
                    cursor.execute(
                        "INSERT INTO urls (url, depth, status) VALUES (?, ?, ?)",
                        (normalized_url, depth, 'pending')
                    )
                    self.queue.put((normalized_url, depth))
                    logger.info(f"Added seed URL: {normalized_url} at depth {depth}")
                except sqlite3.IntegrityError:
                    pass  # URL已存在
            conn.commit()
    
    def _normalize_url(self, url):
        """规范化URL"""
        parsed = urlparse(url)
        if not parsed.scheme:
            return None
        # 移除URL中的片段标识符
        return parsed.scheme + "://" + parsed.netloc + parsed.path
    
    def _get_domain(self, url):
        """从URL中提取域名"""
        return urlparse(url).netloc
    
    def _should_crawl(self, url, depth):
        """决定是否应该爬取该URL"""
        # 检查深度限制
        if depth > self.max_depth:
            return False
            
        # 检查URL是否已爬取
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            cursor.execute(
                "SELECT content_hash, last_crawled FROM urls WHERE url = ?", 
                (url,)
            )
            row = cursor.fetchone()
            
            if not row:
                return True  # 新URL,需要爬取
                
            content_hash, last_crawled = row
            # 如果从未成功爬取过,则重试
            if last_crawled is None:
                return True
                
            # 如果上次爬取时间超过24小时,则重新爬取
            if time.time() - last_crawled > 24 * 3600:
                return True
                
        return False
    
    def _respect_politeness(self, domain):
        """遵守爬取礼貌规则,避免对同一域名请求过快"""
        with self.lock:
            with sqlite3.connect(self.db_path) as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT last_request FROM domain_delays WHERE domain = ?", 
                    (domain,)
                )
                row = cursor.fetchone()
                
                last_request = 0
                if row:
                    last_request = row[0]
                
                # 计算需要等待的时间
                elapsed = time.time() - last_request
                if elapsed < self.politeness_delay:
                    wait_time = self.politeness_delay - elapsed
                    logger.debug(f"Respecting politeness for {domain}, waiting {wait_time:.2f}s")
                    time.sleep(wait_time)
                
                # 更新最后请求时间
                cursor.execute(
                    "INSERT OR REPLACE INTO domain_delays (domain, last_request) VALUES (?, ?)",
                    (domain, time.time())
                )
                conn.commit()
    
    def _fetch_url(self, url):
        """获取URL内容"""
        try:
            response = self.session.get(url, timeout=10)
            response.raise_for_status()  # 检查HTTP错误
            return response.content, response.status_code
        except requests.RequestException as e:
            logger.error(f"Error fetching {url}: {str(e)}")
            return None, None
    
    def _extract_links(self, content, base_url):
        """从HTML内容中提取链接"""
        soup = BeautifulSoup(content, 'html.parser')
        links = set()
        
        # 提取所有<a>标签的href
        for a_tag in soup.find_all('a', href=True):
            href = a_tag['href'].strip()
            if not href or href.startswith('javascript:'):
                continue
            
            # 解析相对URL
            absolute_url = urljoin(base_url, href)
            normalized_url = self._normalize_url(absolute_url)
            if normalized_url:
                links.add(normalized_url)
        
        return list(links)
    
    def _calculate_hash(self, content):
        """计算内容哈希值"""
        return hashlib.sha256(content).hexdigest()
    
    def _process_page(self, url, depth, content, status_code):
        """处理页面内容"""
        # 计算内容哈希
        content_hash = self._calculate_hash(content)
        
        # 检查内容是否已存在
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id FROM urls WHERE content_hash = ?", 
                (content_hash,)
            )
            existing_id = cursor.fetchone()
            
            if existing_id:
                logger.info(f"Content already exists for {url}, skipping processing")
            else:
                # 处理内容 - 用户可重写此部分
                self.process_content(url, content)
        
        # 更新数据库
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            cursor.execute(
                """UPDATE urls 
                SET status = ?, content_hash = ?, last_crawled = ? 
                WHERE url = ?""",
                ('completed', content_hash, time.time(), url)
            )
            conn.commit()
        
        # 提取链接
        links = self._extract_links(content, url)
        new_depth = depth + 1
        new_urls = []
        
        # 添加新链接到数据库和队列
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            for link in links:
                # 检查是否应该爬取
                if not self._should_crawl(link, new_depth):
                    continue
                
                # 插入新URL或更新现有URL
                try:
                    cursor.execute(
                        """INSERT INTO urls (url, depth, status) 
                        VALUES (?, ?, ?)
                        ON CONFLICT(url) DO UPDATE SET depth = ?, status = ?""",
                        (link, new_depth, 'pending', new_depth, 'pending')
                    )
                    new_urls.append(link)
                except sqlite3.Error as e:
                    logger.error(f"Error adding URL {link}: {str(e)}")
            
            conn.commit()
        
        # 添加新URL到队列
        for link in new_urls:
            self.queue.put((link, new_depth))
        
        # 更新统计
        with self.lock:
            self.stats['total_crawled'] += 1
            self.stats['total_links_found'] += len(links)
            self.stats['last_crawl_time'] = time.time()
        
        logger.info(f"Crawled: {url} | Depth: {depth} | Links found: {len(links)} | New URLs: {len(new_urls)}")
    
    def process_content(self, url, content):
        """
        处理页面内容的方法 - 用户应重写此方法以实现具体业务逻辑
        
        参数:
            url: 当前页面的URL
            content: 页面内容(字节)
        """
        # 示例: 保存HTML文件
        domain = self._get_domain(url)
        path = urlparse(url).path
        filename = re.sub(r'[^\w\-_\.]', '_', path) or "index.html"
        
        # 创建域目录
        os.makedirs(f"crawled_pages/{domain}", exist_ok=True)
        
        # 保存文件
        with open(f"crawled_pages/{domain}/{filename}", "wb") as f:
            f.write(content)
        
        logger.debug(f"Saved content for {url}")
    
    def _worker(self):
        """爬虫工作线程"""
        while True:
            url, depth = self.queue.get()
            
            # 检查是否应该爬取
            if not self._should_crawl(url, depth):
                self.queue.task_done()
                continue
                
            domain = self._get_domain(url)
            self._respect_politeness(domain)
            
            # 获取URL内容
            content, status_code = self._fetch_url(url)
            
            # 处理响应
            if content:
                self._process_page(url, depth, content, status_code)
            else:
                # 标记为失败
                with sqlite3.connect(self.db_path) as conn:
                    cursor = conn.cursor()
                    cursor.execute(
                        "UPDATE urls SET status = ? WHERE url = ?",
                        ('failed', url)
                    )
                    conn.commit()
                logger.warning(f"Failed to crawl {url}")
            
            self.queue.task_done()
    
    def start_crawling(self, resume=False):
        """
        启动爬虫
        
        参数:
            resume: 是否从上次中断处继续
        """
        logger.info("Starting incremental crawler")
        
        if resume:
            # 恢复未完成的URL
            with sqlite3.connect(self.db_path) as conn:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT url, depth FROM urls WHERE status IN ('pending', 'failed')"
                )
                pending_urls = cursor.fetchall()
                
                for url, depth in pending_urls:
                    self.queue.put((url, depth))
                    logger.info(f"Resuming pending URL: {url} at depth {depth}")
        
        # 启动工作线程
        for i in range(self.max_threads):
            t = Thread(target=self._worker, daemon=True)
            t.start()
        
        # 等待队列完成
        self.queue.join()
        
        # 计算总时间
        total_time = time.time() - self.stats['start_time']
        logger.info(f"Crawling completed! Total URLs crawled: {self.stats['total_crawled']}")
        logger.info(f"Total links found: {self.stats['total_links_found']}")
        logger.info(f"Total time: {total_time:.2f} seconds")
    
    def print_stats(self):
        """打印爬虫统计信息"""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            
            # 获取URL统计
            cursor.execute("SELECT status, COUNT(*) FROM urls GROUP BY status")
            status_counts = cursor.fetchall()
            
            # 获取最常爬取的域名
            cursor.execute('''
            SELECT domain, COUNT(*) as count 
            FROM (
                SELECT 
                    CASE 
                        WHEN INSTR(url, '://') > 0 
                        THEN SUBSTR(url, INSTR(url, '://') + 3, INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1) 
                    END as domain 
                FROM urls
            ) 
            WHERE domain IS NOT NULL 
            GROUP BY domain 
            ORDER BY count DESC 
            LIMIT 5
            ''')
            top_domains = cursor.fetchall()
        
        print("\n===== Crawler Statistics =====")
        print(f"Total URLs crawled: {self.stats['total_crawled']}")
        print(f"Total links found: {self.stats['total_links_found']}")
        
        print("\nURL Status Summary:")
        for status, count in status_counts:
            print(f"  {status}: {count}")
        
        print("\nTop Domains:")
        for domain, count in top_domains:
            print(f"  {domain}: {count} URLs")
        
        if self.stats['last_crawl_time'] > 0:
            last_crawl = time.strftime(
                "%Y-%m-%d %H:%M:%S", 
                time.localtime(self.stats['last_crawl_time'])
            )
            print(f"\nLast crawl time: {last_crawl}")


# 使用示例
if __name__ == "__main__":
    # 创建爬虫实例
    crawler = IncrementalCrawler(
        max_threads=3,
        max_depth=2,
        politeness_delay=2.0
    )
    
    # 添加种子URL
    seed_urls = [
        "https://example.com",
        "https://www.wikipedia.org",
        "https://github.com"
    ]
    crawler.add_seed_urls(seed_urls)
    
    # 启动爬虫 (resume=True 可以从上次中断处继续)
    try:
        crawler.start_crawling(resume=False)
    except KeyboardInterrupt:
        logger.info("Crawler interrupted by user")
    
    # 打印统计信息
    crawler.print_stats()

增量式爬虫核心功能

  1. URL去重与状态管理:

    • 使用SQLite数据库存储所有URL及其状态
    • 基于URL和内容哈希进行去重
    • 记录最后爬取时间,避免重复爬取
  2. 增量更新机制:

    • 仅爬取新URL或24小时内未更新的URL
    • 内容哈希比对,避免处理相同内容
  3. 礼貌爬取策略:

    • 域名级别的请求延迟控制
    • 可配置的延迟时间
    • 避免对同一域名请求过快
  4. 断点续爬功能:

    • 记录爬取状态到数据库
    • 支持从"pending"或"failed"状态恢复爬取
  5. 多线程支持:

    • 可配置的线程数量
    • 线程安全的数据访问

使用说明

  1. 初始化爬虫:

    crawler = IncrementalCrawler(
        db_path='crawler.db',     # 数据库路径
        max_threads=5,            # 最大线程数
        max_depth=3,              # 最大爬取深度
        politeness_delay=1.0,     # 请求延迟(秒)
        user_agent="Custom Agent" # 自定义User-Agent
    )
    
  2. 添加种子URL:

    crawler.add_seed_urls([
        "https://example.com",
        "https://www.example.org"
    ])
    
  3. 自定义内容处理:

    class MyCrawler(IncrementalCrawler):
        def process_content(self, url, content):
            # 实现自定义处理逻辑
            # 例如:解析内容、存储数据等
            pass
    
  4. 启动爬虫:

    # 首次爬取
    crawler.start_crawling(resume=False)
    
    # 断点续爬
    crawler.start_crawling(resume=True)
    
  5. 查看统计信息:

    crawler.print_stats()
    

数据库结构

urls 表

字段类型描述
idINTEGER主键ID
urlTEXTURL地址(唯一)
depthINTEGER爬取深度
statusTEXT状态(pending/completed/failed)
content_hashTEXT内容哈希值
last_crawledREAL最后爬取时间
created_atREAL创建时间

domain_delays 表

字段类型描述
domainTEXT域名(主键)
last_requestREAL最后请求时间

注意事项

  1. 遵守网站的robots.txt规则
  2. 根据目标网站调整爬取延迟(politeness_delay)
  3. 重写process_content方法实现具体业务逻辑
  4. 避免爬取敏感或受版权保护的内容
  5. 定期备份数据库文件

这个模版就是以前我做过的一个项目,主要提供了增量式爬虫的核心功能,具体情况可以根据需求进行扩展和优化。不管是小型爬虫还是大型增量爬虫都可以完美胜任,如果有问题可以留言讨论。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2403854.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【JVM】三色标记法原理

在JVM中&#xff0c;三色标记法是GC过程中对象状态的判断依据&#xff0c;回收前给对象设置上不同的三种颜色&#xff0c;三色分为白色、灰色、黑色。根据颜色的不同&#xff0c;决定对象是否要被回收。 白色表示&#xff1a; 初始状态&#xff1a;所有对象未被 GC 访问。含义…

【uniapp开发】picker组件的使用

项目uniapp&#xff0c;结合fastadmin后端开发 picker组件的官方文档说明 https://en.uniapp.dcloud.io/component/picker.html#普通选择器 先看效果&#xff1a; 1、实现设备类型的筛选&#xff1b;2、实现设备状态的筛选&#xff1b; 前端代码&#xff08;节选&#xff0…

【HarmonyOS Next之旅】DevEco Studio使用指南(三十一) -> 同步云端代码至DevEco Studio工程

目录 1 -> 同步云函数/云对象 1.1 -> 同步单个云函数/云对象 1.2 -> 批量同步云函数/云对象 2 -> 同步云数据库 2.1 -> 同步单个对象类型 2.2 -> 批量同步对象类型 3 -> 一键同步云侧代码 1 -> 同步云函数/云对象 说明 对于使用DevEco Studio…

go-zero微服务入门案例

一、go-zero微服务环境安装 1、go-zero脚手架的安装 go install github.com/zeromicro/go-zero/tools/goctllatest2、etcd的安装下载地址根据自己电脑操作系统下载对应的版本&#xff0c;具体的使用自己查阅文章 二、创建一个user-rpc服务 1、定义user.proto文件 syntax &qu…

Python控制台输出彩色字体指南

在Python开发中&#xff0c;有时我们需要在控制台输出彩色文本以提高可读性或创建更友好的用户界面。本文将介绍如何使用colorama库来实现这一功能。 为什么需要彩色输出&#xff1f; 提高可读性&#xff1a;重要信息可以用不同颜色突出显示更好的用户体验&#xff1a;错误信息…

开源之夏·西安电子科技大学站精彩回顾:OpenTiny开源技术下沉校园,点燃高校开发者技术热情

开源之夏2025编程活动正在如火如荼的进行中&#xff0c;当前也迎来了报名的倒计时阶段&#xff0c;开源之夏组织方也通过高校行系列活动进入各大高校&#xff0c;帮助高校开发者科普开源文化、开源活动、开源技术。 6月4日 开源之夏携手多位开源技术大咖、经验型选手走进西安电…

解决数据库重启问题

最近部署软件时&#xff0c;发现mysql会一直在重启&#xff0c;记录下解决办法&#xff1a; 1.删除/home/dataexa/install/docker/datas/mysql路径下的data文件夹 2.重新构建mysql docker-compose up -d --build mysql 3.停掉所有应用&#xff0c;在全部重启&#xff1a; do…

前后端交互过程中—各类文件/图片的上传、下载、显示转换

前后端交互过程中—各类文件/图片的上传、下载、显示转换 图片补充&#xff1a;new Blob()URL.createObjectURL()替代方案&#xff1a;FileReader.readAsDataURL()​​对比&#xff1a; tiff文件TIFF库TIFF转换通过url转换tiff文件为png通过文件选择的方式转换tiff文件为png 下…

数据库同步是什么意思?数据库架构有哪些?

目录 一、数据库同步是什么 &#xff08;一&#xff09;基本概念 &#xff08;二&#xff09;数据库同步的类型 &#xff08;三&#xff09;数据库同步的实现方式 二、数据库架构的类型 &#xff08;一&#xff09;单机架构 &#xff08;二&#xff09;主从复制架构 &a…

【数据结构】详解算法复杂度:时间复杂度和空间复杂度

&#x1f525;个人主页&#xff1a;艾莉丝努力练剑 ❄专栏传送门&#xff1a;《C语言》、《数据结构与算法》 &#x1f349;学习方向&#xff1a;C/C方向 ⭐️人生格言&#xff1a;为天地立心&#xff0c;为生民立命&#xff0c;为往圣继绝学&#xff0c;为万世开太平 前言&…

Rest-Assured API 测试:基于 Java 和 TestNG 的接口自动化测试

1. 右键点击项目的文件夹&#xff0c;选择 New > File。 2. 输入文件名&#xff0c;例如 notes.md&#xff0c;然后点击 OK。 3. 选择项目类型 在左侧的 Generators 部分&#xff0c;选择 Maven Archetype&#xff0c;这将为你生成一个基于 Maven 的项目。 4. 配置项目基…

react public/index.html文件使用env里面的变量

env文件 ENVdevelopment NODE_ENVdevelopment REACT_APP_URL#{REACT_APP_URL}# REACT_APP_CLIENTID#{REACT_APP_CLIENTID}# REACT_APP_TENANTID#{REACT_APP_TENANTID}# REACT_APP_REDIRECTURL#{REACT_APP_REDIRECTURL}# REACT_APP_DOMAIN_SCRIPT#{REACT_APP_DOMAIN_SCRIPT}#pu…

chili3d 笔记17 c++ 编译hlr 带隐藏线工程图

这个要注册不然emscripten编译不起来 --------------- 行不通 ---------------- 结构体 using LineSegment std::pair<gp_Pnt, gp_Pnt>;using LineSegmentList std::vector<LineSegment>; EMSCRIPTEN_BINDINGS(Shape_Projection) {value_object<LineSegment&g…

创建一个纯直线组成的字体库

纯直线组成的字体&#xff0c;一个“却”由五组坐标点组成&#xff0c;存储5个点共占21字节&#xff0c;使用简单&#xff0c;只要画直线即可&#xff0c; “微软雅黑”&#xff0c;2个轮廓&#xff0c;55坐标点&#xff0c;使用复杂&#xff0c;还填充。 自创直线字体 “微软…

Linux进程(中)

目录 进程等待 为什么有进程等待 什么是进程等待 怎么做到进程等待 wait waitpid 进程等待 为什么有进程等待 僵尸进程无法杀死&#xff0c;需要进程等待来消灭他&#xff0c;进而解决内存泄漏问题--必须解决的 我们要通过进程等待&#xff0c;获得子进程退出情况--知…

【计算机组成原理】计算机硬件的基本组成、详细结构、工作原理

引言 计算机如同现代科技的“大脑”&#xff0c;其硬件结构的设计逻辑承载着信息处理的核心奥秘。从早期程序员手动输入指令的低效操作&#xff0c;到冯诺依曼提出“存储程序”概念引发的革命性突破&#xff0c;计算机硬件经历了从机械操控到自动化逻辑的蜕变。本文将深入拆解…

MVC分层架构模式深入剖析

&#x1f504; MVC 交互流程 #mermaid-svg-5xGt0Ka13DviDk15 {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-5xGt0Ka13DviDk15 .error-icon{fill:#552222;}#mermaid-svg-5xGt0Ka13DviDk15 .error-text{fill:#552222…

新能源汽车热管理核心技术解析:冬季续航提升40%的行业方案

新能源汽车热管理核心技术解析&#xff1a;冬季续航提升40%的行业方案 摘要&#xff1a;突破续航焦虑的关键在热能循环&#xff01; &#x1f449; 本文耗时72小时梳理行业前沿方案&#xff0c;含特斯拉/比亚迪等8家车企热管理系统原理图 一、热管理为何成新能源车决胜关键&am…

华为云Flexus+DeepSeek征文|DeepSeek-V3/R1开通指南及使用心得

&#x1f3c6;作者简介&#xff0c;黑夜开发者&#xff0c;CSDN领军人物&#xff0c;全栈领域优质创作者✌&#xff0c;CSDN博客专家&#xff0c;阿里云社区专家博主&#xff0c;2023年CSDN全站排名top 28。 &#x1f3c6;数年电商行业从业经验&#xff0c;AWS/阿里云资深使用用…

运行示例程序和一些基本操作

欢迎 ----> 示例 --> 选择sample CTRL B 编译代码 CTRL R 运行exe 项目 中 Shadow build 表示是否 编译生成文件和 源码是否放一块 勾上不在同一个地方 已有项目情况下怎么打开项目 方法一: 左键双击 xxx.pro 方法二: 文件菜单里面 选择打开项目