• 用 Lucene 加速 Web 搜索应用程序的开发


    周 登朋 (zhoudengpeng@yahoo.com.cn), 软件工程师, 上海交通大学

    2006 年 9 月 06 日 

    在本篇文章中,你会学习到如何利用 Lucene 实现高级搜索功能以及如何利用 Lucene 来创建 Web 搜索应用程序。通过这些学习,你就可以利用 Lucene 来创建自己的搜索应用程序。

    架构概览

    通常一个 Web 搜索引擎的架构分为前端和后端两部分,就像图一中 所示。在前端流程中,用户在搜索引擎提供的界面中输入要搜索的关键词,这里提到的用户界面一般是一个带有输入框的 Web 页面,然后应用程序将搜索的关键词解析成搜索引擎可以理解的形式,并在索引文件上进行搜索操作。在排序后,搜索引擎返回搜索结果给用户。在后端流程中,网 络爬虫或者机器人从因特网上获取 Web 页面,然后索引子系统解析这些 Web 页面并存入索引文件中。如果你想利用 Lucene 来创建一个 Web 搜索应用程序,那么它的架构也和上面所描述的类似,就如图一中所示。


    Figure 1. Web 搜索引擎架构
    Web搜索引擎架构

    利用 Lucene 实现高级搜索

    Lucene 支持多种形式的高级搜索,我们在这一部分中会进行探讨,然后我会使用 Lucene 的 API 来演示如何实现这些高级搜索功能。

    布尔操作符

    大多数的搜索引擎都会提供布尔操作符让用户可以组合查询,典型的布尔操作符有 AND, OR, NOT。Lucene 支持 5 种布尔操作符,分别是 AND, OR, NOT, 加(+), 减(-)。接下来我会讲述每个操作符的用法。

    • OR: 如果你要搜索含有字符 A 或者 B 的文档,那么就需要使用 OR 操作符。需要记住的是,如果你只是简单的用空格将两个关键词分割开,其实在搜索的时候搜索引擎会自动在两个关键词之间加上 OR 操作符。例如,“Java OR Lucene” 和 “Java Lucene” 都是搜索含有 Java 或者含有 Lucene 的文档。
    • AND: 如果你需要搜索包含一个以上关键词的文档,那么就需要使用 AND 操作符。例如,“Java AND Lucene” 返回所有既包含 Java 又包含 Lucene 的文档。
    • NOT: Not 操作符使得包含紧跟在 NOT 后面的关键词的文档不会被返回。例如,如果你想搜索所有含有 Java 但不含有 Lucene 的文档,你可以使用查询语句 “Java NOT Lucene”。但是你不能只对一个搜索词使用这个操作符,比如,查询语句 “NOT Java” 不会返回任何结果。
    • 加号(+): 这个操作符的作用和 AND 差不多,但它只对紧跟着它的一个搜索词起作用。例如,如果你想搜索一定包含 Java,但不一定包含 Lucene 的文档,就可以使用查询语句“+Java Lucene”。
    • 减号(-): 这个操作符的功能和 NOT 一样,查询语句 “Java -Lucene” 返回所有包含 Java 但不包含 Lucene 的文档。

    接下来我们看一下如何利用 Lucene 提供的 API 来实现布尔查询。清单1 显示了如果利用布尔操作符进行查询的过程。


    清单1:使用布尔操作符

      //Test boolean operator
    public void testOperator(String indexDirectory) throws Exception{
       Directory dir 
    = FSDirectory.getDirectory(indexDirectory,false);
       IndexSearcher indexSearcher 
    = new IndexSearcher(dir);
       String[] searchWords 
    = {"Java AND Lucene""Java NOT Lucene""Java OR Lucene"
                        
    "+Java +Lucene""+Java -Lucene"}
    ;
       Analyzer language 
    = new StandardAnalyzer();
       Query query;
       
    for(int i = 0; i < searchWords.length; i++){
          query 
    = QueryParser.parse(searchWords[i], "title", language);
          Hits results 
    = indexSearcher.search(query);
          System.out.println(results.length() 
    + "search results for query " + searchWords[i]);
       }

    }


    域搜索(Field Search)

    Lucene 支持域搜索,你可以指定一次查询是在哪些域(Field)上进行。例如,如果索引的文档包含两个域,TitleContent,你就可以使用查询 “Title: Lucene AND Content: Java” 来返回所有在 Title 域上包含 Lucene 并且在 Content 域上包含 Java 的文档。清单 2 显示了如何利用 Lucene 的 API 来实现域搜索。


    清单2:实现域搜索

    //Test field search
    public void testFieldSearch(String indexDirectory) throws Exception{
        Directory dir 
    = FSDirectory.getDirectory(indexDirectory,false);
        IndexSearcher indexSearcher 
    = new IndexSearcher(dir);
        String searchWords 
    = "title:Lucene AND content:Java";
        Analyzer language 
    = new StandardAnalyzer();
        Query query 
    = QueryParser.parse(searchWords, "title", language);
        Hits results 
    = indexSearcher.search(query);
        System.out.println(results.length() 
    + "search results for query " + searchWords);
    }


    通配符搜索(Wildcard Search)

    Lucene 支持两种通配符:问号(?)和星号(*)。你可以使用问号(?)来进行单字符的通配符查询,或者利用星号(*)进行多字符的通配符查询。例如,如果你想搜 索 tiny 或者 tony,你就可以使用查询语句 “t?ny”;如果你想查询 Teach, Teacher 和 Teaching,你就可以使用查询语句 “Teach*”。清单3 显示了通配符查询的过程。


    清单3:进行通配符查询

    //Test wildcard search
    public void testWildcardSearch(String indexDirectory)throws Exception{
       Directory dir 
    = FSDirectory.getDirectory(indexDirectory,false);
       IndexSearcher indexSearcher 
    = new IndexSearcher(dir);
       String[] searchWords 
    = {"tex*""tex?""?ex*"};
       Query query;
       
    for(int i = 0; i < searchWords.length; i++){
          query 
    = new WildcardQuery(new Term("title",searchWords[i]));
          Hits results 
    = indexSearcher.search(query);
          System.out.println(results.length() 
    + "search results for query " + searchWords[i]);
       }

    }


    模糊查询

    Lucene 提供的模糊查询基于编辑距离算法(Edit distance algorithm)。你可以在搜索词的尾部加上字符 ~ 来进行模糊查询。例如,查询语句 “think~” 返回所有包含和 think 类似的关键词的文档。清单 4 显示了如果利用 Lucene 的 API 进行模糊查询的代码。


    清单4:实现模糊查询

    //Test fuzzy search
    public void testFuzzySearch(String indexDirectory)throws Exception{
       Directory dir 
    = FSDirectory.getDirectory(indexDirectory,false);
       IndexSearcher indexSearcher 
    = new IndexSearcher(dir);
       String[] searchWords 
    = {"text""funny"};
       Query query;
       
    for(int i = 0; i < searchWords.length; i++){
          query 
    = new FuzzyQuery(new Term("title",searchWords[i]));
          Hits results 
    = indexSearcher.search(query);
          System.out.println(results.length() 
    + "search results for query " + searchWords[i]);
       }

    }


    范围搜索(Range Search)

    范围搜索匹配某个域上的值在一定范围的文档。例如,查询 “age:[18 TO 35]” 返回所有 age 域上的值在 18 到 35 之间的文档。清单5显示了利用 Lucene 的 API 进行返回搜索的过程。


    清单5:测试范围搜索

    //Test range search
    public void testRangeSearch(String indexDirectory)throws Exception{
        Directory dir 
    = FSDirectory.getDirectory(indexDirectory,false);
        IndexSearcher indexSearcher 
    = new IndexSearcher(dir);
        Term begin 
    = new Term("birthDay","20000101");
        Term end   
    = new Term("birthDay","20060606");
        Query query 
    = new RangeQuery(begin,end,true);
        Hits results 
    = indexSearcher.search(query);
        System.out.println(results.length() 
    + "search results is returned");
    }


    在 Web 应用程序中集成 Lucene

    接下来我们开发一个 Web 应用程序利用 Lucene 来检索存放在文件服务器上的 HTML 文档。在开始之前,需要准备如下环境:

    1. Eclipse 集成开发环境
    2. Tomcat 5.0
    3. Lucene Library
    4. JDK 1.5

    这个例子使用 Eclipse 进行 Web 应用程序的开发,最终这个 Web 应用程序跑在 Tomcat 5.0 上面。在准备好开发所必需的环境之后,我们接下来进行 Web 应用程序的开发。

    1、创建一个动态 Web 项目

    1. 在 Eclipse 里面,选择 File > New > Project,然后再弹出的窗口中选择动态 Web 项目,如图二所示。


    图二:创建动态Web项目
    创建动态Web项目

    1. 在创建好动态 Web 项目之后,你会看到创建好的项目的结构,如图三所示,项目的名称为 sample.dw.paper.lucene。


    图三:动态 Web 项目的结构
    动态 Web 项目的结构

    2. 设计 Web 项目的架构

    在我们的设计中,把该系统分成如下四个子系统:

    1. 用户接口: 这个子系统提供用户界面使用户可以向 Web 应用程序服务器提交搜索请求,然后搜索结果通过用户接口来显示出来。我们用一个名为 search.jsp 的页面来实现该子系统。
    2. 请求管理器: 这个子系统管理从客户端发送过来的搜索请求并把搜索请求分发到搜索子系统中。最后搜索结果从搜索子系统返回并最终发送到用户接口子系统。我们使用一个 Servlet 来实现这个子系统。
    3. 搜索子系统: 这个子系统负责在索引文件上进行搜索并把搜索结构传递给请求管理器。我们使用 Lucene 提供的 API 来实现该子系统。
    4. 索引子系统: 这个子系统用来为 HTML 页面来创建索引。我们使用 Lucene 的 API 以及 Lucene 提供的一个 HTML 解析器来创建该子系统。

    图4 显示了我们设计的详细信息,我们将用户接口子系统放到 webContent 目录下面。你会看到一个名为 search.jsp 的页面在这个文件夹里面。请求管理子系统在包 sample.dw.paper.lucene.servlet 下面,类 SearchController 负责功能的实现。搜索子系统放在包 sample.dw.paper.lucene.search 当中,它包含了两个类,SearchManagerSearchResultBean,第一个类用来实现搜索功能,第二个类用来描述搜索结果的结构。索引子系统放在包 sample.dw.paper.lucene.index 当中。类 IndexManager 负责为 HTML 文件创建索引。该子系统利用包 sample.dw.paper.lucene.util 里面的类 HTMLDocParser 提供的方法 getTitlegetContent 来对 HTML 页面进行解析。


    图四:项目的架构设计
    项目的架构设计

    3. 子系统的实现

    在分析了系统的架构设计之后,我们接下来看系统实现的详细信息。

    1. 用户接口: 这个子系统有一个名为 search.jsp 的 JSP 文件来实现,这个 JSP 页面包含两个部分。第一部分提供了一个用户接口去向 Web 应用程序服务器提交搜索请求,如图5所示。注意到这里的搜索请求发送到了一个名为 SearchController 的 Servlet 上面。Servlet 的名字和具体实现的类的对应关系在 web.xml 里面指定。


    图5:向Web服务器提交搜索请求
    向Web服务器提交搜索请求

    这个JSP的第二部分负责显示搜索结果给用户,如图6所示:


    图6:显示搜索结果
    显示搜索结果

    1. 请求管理器: 一个名为 SearchController 的 servlet 用来实现该子系统。清单6给出了这个类的源代码。


    清单6:请求管理器的实现

    package sample.dw.paper.lucene.servlet;

    import java.io.IOException;
    import java.util.List;

    import javax.servlet.RequestDispatcher;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;

    import sample.dw.paper.lucene.search.SearchManager;

    /**
     * This servlet is used to deal with the search request
     * and return the search results to the client
     
    */

    public class SearchController extends HttpServlet{

        
    private static final long serialVersionUID = 1L;

        
    public void doPost(HttpServletRequest request, HttpServletResponse response)
                          
    throws IOException, ServletException{
            String searchWord 
    = request.getParameter("searchWord");
            SearchManager searchManager 
    = new SearchManager(searchWord);
            List searchResult 
    = null;
            searchResult 
    = searchManager.search();
            RequestDispatcher dispatcher 
    = request.getRequestDispatcher("search.jsp");
            request.setAttribute(
    "searchResult",searchResult);
            dispatcher.forward(request, response);
        }


        
    public void doGet(HttpServletRequest request, HttpServletResponse response)
                         
    throws IOException, ServletException{
            doPost(request, response);
        }

    }


    清单6中,doPost 方法从客户端获取搜索词并创建类 SearchManager 的一个实例,其中类 SearchManager 在搜索子系统中进行了定义。然后,SearchManager 的方法 search 会被调用。最后搜索结果被返回到客户端。

    1. 搜索子系统: 在这个子系统中,我们定义了两个类:SearchManagerSearchResultBean。第一个类用来实现搜索功能,第二个类是个JavaBean,用来描述搜索结果的结构。清单7给出了类 SearchManager 的源代码。


    清单7:搜索功能的实现

    package sample.dw.paper.lucene.search;

    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;

    import org.apache.lucene.analysis.Analyzer;
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    import org.apache.lucene.queryParser.ParseException;
    import org.apache.lucene.queryParser.QueryParser;
    import org.apache.lucene.search.Hits;
    import org.apache.lucene.search.IndexSearcher;
    import org.apache.lucene.search.Query;

    import sample.dw.paper.lucene.index.IndexManager;

    /**
     * This class is used to search the 
     * Lucene index and return search results
     
    */

    public class SearchManager {
        
        
    private String searchWord;
        
        
    private IndexManager indexManager;
        
        
    private Analyzer analyzer;
        
        
    public SearchManager(String searchWord){
            
    this.searchWord   =  searchWord;
            
    this.indexManager =  new IndexManager();
            
    this.analyzer     =  new StandardAnalyzer();
        }

        
        
    /**
         * do search
         
    */

        
    public List search(){
            List searchResult 
    = new ArrayList();
            
    if(false == indexManager.ifIndexExist()){
            
    try {
                
    if(false == indexManager.createIndex()){
                    
    return searchResult;
                }

            }
     catch (IOException e) {
              e.printStackTrace();
              
    return searchResult;
            }

            }

            
            IndexSearcher indexSearcher 
    = null;

            
    try{
                indexSearcher 
    = new IndexSearcher(indexManager.getIndexDir());
            }
    catch(IOException ioe){
                ioe.printStackTrace();
            }


            QueryParser queryParser 
    = new QueryParser("content",analyzer);
            Query query 
    = null;
            
    try {
                query 
    = queryParser.parse(searchWord);
            }
     catch (ParseException e) {
              e.printStackTrace();
            }

            
    if(null != query >> null != indexSearcher){            
                
    try {
                    Hits hits 
    = indexSearcher.search(query);
                    
    for(int i = 0; i < hits.length(); i ++){
                        SearchResultBean resultBean 
    = new SearchResultBean();
                        resultBean.setHtmlPath(hits.doc(i).get(
    "path"));
                        resultBean.setHtmlTitle(hits.doc(i).get(
    "title"));
                        searchResult.add(resultBean);
                    }

                }
     catch (IOException e) {
                    e.printStackTrace();
                }

            }

            
    return searchResult;
        }

    }


    清单7中,注意到在这个类里面有三个私有属性。第一个是 searchWord,代表了来自客户端的搜索词。第二个是 indexManager,代表了在索引子系统中定义的类 IndexManager 的一个实例。第三个是 analyzer,代表了用来解析搜索词的解析器。现在我们把注意力放在方法 search 上面。这个方法首先检查索引文件是否已经存在,如果已经存在,那么就在已经存在的索引上进行检索,如果不存在,那么首先调用类 IndexManager 提供的方法来创建索引,然后在新创建的索引上进行检索。搜索结果返回后,这个方法从搜索结果中提取出需要的属性并为每个搜索结果生成类 SearchResultBean 的一个实例。最后这些 SearchResultBean 的实例被放到一个列表里面并返回给请求管理器。

    在类 SearchResultBean 中,含有两个属性,分别是 htmlPathhtmlTitle,以及这个两个属性的 get 和 set 方法。这也意味着我们的搜索结果包含两个属性:htmlPathhtmlTitle,其中 htmlPath 代表了 HTML 文件的路径,htmlTitle 代表了 HTML 文件的标题。

    1. 索引子系统: 类 IndexManager 用来实现这个子系统。清单8 给出了这个类的源代码。


    清单8:索引子系统的实现

    package sample.dw.paper.lucene.index;

    import java.io.File;
    import java.io.IOException;
    import java.io.Reader;

    import org.apache.lucene.analysis.Analyzer;
    import org.apache.lucene.analysis.standard.StandardAnalyzer;
    import org.apache.lucene.document.Document;
    import org.apache.lucene.document.Field;
    import org.apache.lucene.index.IndexWriter;
    import org.apache.lucene.store.Directory;
    import org.apache.lucene.store.FSDirectory;

    import sample.dw.paper.lucene.util.HTMLDocParser;

    /**
     * This class is used to create an index for HTML files
     *
     
    */

    public class IndexManager {

        
    //the directory that stores HTML files 
        private final String dataDir  = "c:/dataDir";

        
    //the directory that is used to store a Lucene index
        private final String indexDir = "c:/indexDir";

        
    /**
         * create index
         
    */

        
    public boolean createIndex() throws IOException{
            
    if(true == ifIndexExist()){
                
    return true;    
            }

            File dir 
    = new File(dataDir);
            
    if(!dir.exists()){
                
    return false;
            }

            File[] htmls 
    = dir.listFiles();
            Directory fsDirectory 
    = FSDirectory.getDirectory(indexDir, true);
            Analyzer  analyzer    
    = new StandardAnalyzer();
            IndexWriter indexWriter 
    = new IndexWriter(fsDirectory, analyzer, true);
            
    for(int i = 0; i < htmls.length; i++){
                String htmlPath 
    = htmls[i].getAbsolutePath();

                
    if(htmlPath.endsWith(".html"|| htmlPath.endsWith(".htm")){
                    addDocument(htmlPath, indexWriter);
                }

            }

            indexWriter.optimize();
            indexWriter.close();
            
    return true;

        }


        
    /**
         * Add one document to the Lucene index
         
    */

        
    public void addDocument(String htmlPath, IndexWriter indexWriter){
            HTMLDocParser htmlParser 
    = new HTMLDocParser(htmlPath);
            String path    
    = htmlParser.getPath();
            String title   
    = htmlParser.getTitle();
            Reader content 
    = htmlParser.getContent();

            Document document 
    = new Document();
            document.add(
    new Field("path",path,Field.Store.YES,Field.Index.NO));
            document.add(
    new Field("title",title,Field.Store.YES,Field.Index.TOKENIZED));
            document.add(
    new Field("content",content));
            
    try {
                  indexWriter.addDocument(document);
        }
     catch (IOException e) {
                  e.printStackTrace();
              }

        }


        
    /**
         * judge if the index exists already
         
    */

        
    public boolean ifIndexExist(){
            File directory 
    = new File(indexDir);
            
    if(0 < directory.listFiles().length){
                
    return true;
            }
    else{
                
    return false;
            }

        }


        
    public String getDataDir(){
            
    return this.dataDir;
        }


        
    public String getIndexDir(){
            
    return this.indexDir;
        }


    }


    这个类包含两个私有属性,分别是 dataDirindexDirdataDir 代表存放等待进行索引的 HTML 页面的路径,indexDir 代表了存放 Lucene 索引文件的路径。类 IndexManager 提供了三个方法,分别是 createIndex, addDocumentifIndexExist。如果索引不存在的话,你可以使用方法 createIndex 去创建一个新的索引,用方法 addDocument 去向一个索引上添加文档。在我们的场景中,一个文档就是一个 HTML 页面。方法 addDocument 会调用由类 HTMLDocParser 提供的方法对 HTML 文档进行解析。你可以使用最后一个方法 ifIndexExist 来判断 Lucene 的索引是否已经存在。

    现在我们来看一下放在包 sample.dw.paper.lucene.util 里面的类 HTMLDocParser。这个类用来从 HTML 文件中提取出文本信息。这个类包含三个方法,分别是 getContentgetTitlegetPath。第一个方法返回去除了 HTML 标记的文本内容,第二个方法返回 HTML 文件的标题,最后一个方法返回 HTML 文件的路径。清单9 给出了这个类的源代码。


    清单9:HTML 解析器

    package sample.dw.paper.lucene.util;

    import java.io.FileInputStream;
    import java.io.FileNotFoundException;
    import java.io.IOException;
    import java.io.InputStream;
    import java.io.InputStreamReader;
    import java.io.Reader;
    import java.io.UnsupportedEncodingException;

    import org.apache.lucene.demo.html.HTMLParser;

    public class HTMLDocParser {
        
    private String htmlPath;

        
    private HTMLParser htmlParser;

        
    public HTMLDocParser(String htmlPath){
            
    this.htmlPath = htmlPath;
            initHtmlParser();
        }


        
    private void initHtmlParser(){
            InputStream inputStream 
    = null;
            
    try {
                inputStream 
    = new FileInputStream(htmlPath);
            }
     catch (FileNotFoundException e) {
                e.printStackTrace();
            }

            
    if(null != inputStream){
                
    try {
                    htmlParser 
    = new HTMLParser(new InputStreamReader(inputStream, "utf-8"));
                }
     catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }

            }

        }


        
    public String getTitle(){
            
    if(null != htmlParser){
                
    try {
                    
    return htmlParser.getTitle();
                }
     catch (IOException e) {
                    e.printStackTrace();
                }
     catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }

        
    return "";
        }


        
    public Reader getContent(){
        
    if(null != htmlParser){
                
    try {
                      
    return htmlParser.getReader();
                  }
     catch (IOException e) {
                      e.printStackTrace();
                  }

            }

            
    return null;
        }


        
    public String getPath(){
            
    return this.htmlPath;        
        }

    }


    5.在 Tomcat 5.0 上运行应用程序

    现在我们可以在 Tomcat 5.0 上运行开发好的应用程序。

    1. 右键单击 search.jsp,然后选择 Run as > Run on Server,如图7所示。


    图7:配置 Tomcat 5.0
    配置 Tomcat 5.0

    1. 在弹出的窗口中,选择 Tomcat v5.0 Server 作为目标 Web 应用程序服务器,然后点击 Next,如图8 所示:


    图8:选择 Tomcat 5.0
    选择 Tomcat 5.0

    1. 现在需要指定用来运行 Web 应用程序的 Apache Tomcat 5.0 以及 JRE 的路径。这里你所选择的 JRE 的版本必须和你用来编译 Java 文件的 JRE 的版本一致。配置好之后,点击 Finish。如 图9 所示。


    图9:完成Tomcat 5.0的配置
    完成Tomcat 5.0的配置

    1. 配置好之后,Tomcat 会自动运行,并且会对 search.jsp 进行编译并显示给用户。如 图10 所示。


    图10:用户界面
    用户界面

    1. 在输入框中输入关键词 “information” 然后单击 Search 按钮。然后这个页面上会显示出搜索结果来,如 图11 所示。


    图11:搜索结果
    搜索结果

    1. 单击搜索结果的第一个链接,页面上就会显示出所链接到的页面的内容。如 图12 所示.


    图12:详细信息
    详细信息

    现在我们已经成功的完成了示例项目的开发,并成功的用Lucene实现了搜索和索引功能。你可以下载这个项目的源代码。

    总结

    Lucene 提供了灵活的接口使我们更加方便的设计我们的 Web 搜索应用程序。如果你想在你的应用程序中加入搜索功能,那么 Lucene 是一个很好的选择。在设计你的下一个带有搜索功能的应用程序的时候可以考虑使用 Lucene 来提供搜索功能。

    下载

    描述 名字 大小 下载方法
    Lucene Web 应用程序示例 wa-lucene2_source_code.zip 504KB HTTP
     
  • 相关阅读:
    数学+高精度 ZOJ 2313 Chinese Girls' Amusement
    最短路(Bellman_Ford) POJ 1860 Currency Exchange
    贪心 Gym 100502E Opening Ceremony
    概率 Gym 100502D Dice Game
    判断 Gym 100502K Train Passengers
    BFS POJ 3278 Catch That Cow
    DFS POJ 2362 Square
    DFS ZOJ 1002/HDOJ 1045 Fire Net
    组合数学(全排列)+DFS CSU 1563 Lexicography
    stack UVA 442 Matrix Chain Multiplication
  • 原文地址:https://www.cnblogs.com/lanzhi/p/6470730.html
Copyright © 2020-2023  润新知