package com.sslibrary.object; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import com.sslibrary.spider.BookDownloader; import com.sslibrary.spider.NJULib; import utils.conversion.MyDecoder; import utils.network.MyHttpRequest; import java.io.IOException; import java.nio.file.Paths; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * 图书的分类。 * <p> * 对应<a href="http://114.212.7.104:8181/markbook/">南京大学馆藏数字化图书平台</a> 中的图书。 * 同时分类名和分类编号满足中图法分类。是树结构。具有查询子分类和查询分类下书籍列表,批量下载分类书籍等功能。 * 如果你没有足够信息构造实例,可以通过{@link RootBookClass}查询所有分类来获取实例。 * 或者{@link com.sslibrary.spider.BookSearch}中的一些方法获取实例。 * * @author padeoe * @Date: 2016/12/08 */ public class BookClass { /** * 分类id,服务器定义的中图法分类id, * 例如"0T0P3010" */ private String id; /** * 分类名称 */ private String name; /** * 父分类 */ private BookClass parent; /** * 子分类列表 */ private Map<String, BookClass> children; /** * 子分类{@link #children}是否已经被加载 */ private boolean isLoaded = false; /** * 查看当对象所使用的cookie * * @return cookie */ public String getCookie() { return cookie; } /** * 设置{@code cookie},BookClass的子每一次子分类加载, * 书籍查询等操作都需要cookie,设置的cookie将会对所有子分类使用, * 以避免频繁获取cookie * * @param cookie cookie */ public void setCookie(String cookie) { this.cookie = cookie; } /** * 查询分类信息时向服务器发送的cookie,初始=null。 * 当调用了需要网络的方法时,将会被初始化。 * 一个{@link BookClass}对象的所有子分类{@link #children}都是用的同一个cookie */ private String cookie; /** * 获取子分类的数量 * * @return 子分类的数量 */ public int getChildCount() { return children.size(); } /** * 获取父分类 * * @return 父分类。如果不存在则为null */ public BookClass getParent() { return parent; } /** * 获取所有子分类。 * 初始为null,若要查看子分类,必须先调用{@link #loadChild()}或者{@link #loadAllChild()}从服务器查询并加载 * * @return 子分类的集合 */ public Set<BookClass> getChildren() { return children.values().stream().collect(Collectors.toSet()); } /** * 查询特定子分类。 * * @param idOrName 子分类的名称或者代号。符合中图法分类。 * @return 子分类 */ public BookClass getChild(String idOrName) { return children.get(idOrName); } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getId() { return id; } public void setId(String id) { this.id = id; } public void setParent(BookClass parent) { this.parent = parent; } public boolean isTerminal() { return false; } /** * 添加一个子分类 * * @param bookClass 子分类 * @return 如果同id的子分类已存在,则返回之前的子分类,如果不存在,则添加并返回null */ public BookClass addChild(BookClass bookClass) { if (bookClass.name != null) { children.putIfAbsent(bookClass.name, bookClass); } return children.putIfAbsent(bookClass.id, bookClass); } /** * 创建并初始化一个书本分类。指定分类编号,分类名称和父分类。 * * @param id 分类编号 * @param name 分类名称 * @param parent 父分类 */ public BookClass(String id, String name, BookClass parent) { this.id = id; this.name = name; this.parent = parent; children = new HashMap<>(); } /** * 创建并初始化一个书本分类。指定分类编号,分类名称和父分类。 * * @param id 分类编号 * @param name 分类名称 */ public BookClass(String id, String name) { this.id = id; this.name = name; children = new HashMap<>(); } /** * 创建一个新初始化的{@code BookClass}对象, * 使之中图法分类标识是{@code id} * * @param id 分类的中图法分类标识。 * 需要和<a href="http://114.212.7.104:8181/markbook/">南京大学馆藏数字化图书平台</a>定义的格式一致 */ public BookClass(String id) { this.id = id; children = new HashMap<>(); this.isLoaded = false; } /** * 加载子分类。仅加载一层子分类,即子分类的子分类不会被加载。 * 当该方法被调用时,会向服务器查询该分类的子分类并更新该对象的{@link #children} * <p> * 如需递归加载子分类,调用{@link #loadAllChild()} * * @throws IOException 从服务器查询子节点出错 */ public void loadChild() throws IOException { if (!isTerminal()) { checkCookie(); String Url = NJULib.baseUrl + "/classifyview"; String data = "fenlei=" + this.getId() + "&lib=markbook"; String result = MyHttpRequest.postWithCookie(data, Url, null, cookie, "UTF-8", "UTF-8", 1000); // System.out.println(result); Document doc = Jsoup.parse(result); Elements li = doc.getElementsByTag("li"); for (Element bookClassId : li) { String id = bookClassId.attr("id"); String name = bookClassId.getElementsByTag("a").text(); boolean hasSubTree = bookClassId.getElementsByTag("img").attr("onClick").contains("getSubTree"); //System.out.println(id+" "+NJULib.decodeUrlUnicode(name)); BookClass child = hasSubTree ? new BookClass(id, MyDecoder.decodeUrlUnicode(name), this) : new TerminalBookClass(id, MyDecoder.decodeUrlUnicode(name), this); child.setCookie(cookie); this.addChild(child); } this.isLoaded = true; } } /** * 迭代加载所有子分类。 * 直至加载到每个分类的末层分类。 * * @throws IOException 从服务器查询时出错 */ public void loadAllChild() throws IOException { if (!isTerminal()) { loadChild(); for (BookClass child : getChildren()) { child.loadAllChild(); } } } /** * 下载分类下所有图书,会迭代测创建分类文件夹 * * @param pathname 存储路径。将在该路径下创建多级分类目录并保存下载的图书 * @param threadNumber 线程数 * @param errorLogPath 错误日志路径 * @throws IOException 连接失败的错误 */ public void downloadWithCataDir(String pathname, int threadNumber, String errorLogPath) throws IOException { if (!isTerminal()) { loadChild(); BookClass[]bookClasses=getChildren().toArray(new BookClass[]{}); for(int i=bookClasses.length-1;i>=0;i--){ bookClasses[i].downloadWithCataDir(Paths.get(pathname, name == null ? id : name).toString(), threadNumber, errorLogPath); } /* for (BookClass child : getChildren()) { child.downloadWithCataDir(Paths.get(pathname, name == null ? id : name).toString(), threadNumber, errorLogPath); }*/ } else { downloadAllBooks(Paths.get(pathname, name == null ? id : name).toString(), threadNumber, errorLogPath); } } /** * 下载分类下所有图书,会迭代测创建分类文件夹 * 下载存储路径为当前路径,线程数为5,错误日志将保存在当前路径,文件名为{@link BookDownloader#ERROR_LOG_NAME} * 可以调用重载{@link #downloadWithCataDir(String, int, String)}设置参数 * * @throws IOException 连接失败的错误 */ public void downloadWithCataDir() throws IOException { downloadWithCataDir(System.getProperty("user.dir"), 5, Paths.get(System.getProperty("user.dir"), BookDownloader.ERROR_LOG_NAME).toString()); } /** * 从服务器获取该分类下图书列表的第{@code page}页。 * 图书列表的分页是服务器做的,每页最多10条图书。 * <p> * 页数的最大值可以根据{@link #queryBooksSize()}自行计算 * * @param page 图书列表的页码 * @return 列表该页记录的图书 * @throws IOException 从服务器查询书本列表时出错 */ public Set<Book> queryBooks(int page) throws IOException { checkCookie(); String data = "fenlei=" + this.id + "&mark=all&Page=" + page + "&totalnumber=-1"; String Url = NJULib.baseUrl + "/markbook/classifybookview.jsp"; String html = MyHttpRequest.postWithCookie(data, Url, null, cookie, "UTF-8", "GBK", 1000); // System.out.println(html); Set<Book> books = queryBooks(html); return books; } /** * 获得某分类下的所有图书 * * @return 分类下所有图书 * @throws IOException 从服务器查询书本列表时出错 */ public Set<Book> queryAllBooks() throws IOException { return queryAllBooks(1); } /** * 获得分类下的所有图书 * * @param threadNumber 线程数 * @return 图书集合 * @throws IOException 连接错误 */ public Set<Book> queryAllBooks(int threadNumber) throws IOException { checkCookie(); String data = "fenlei=" + this.id + "&mark=all&Page=1&totalnumber=-1"; String Url = NJULib.baseUrl + "/markbook/classifybookview.jsp"; String html = MyHttpRequest.postWithCookie(data, Url, null, cookie, "UTF-8", "GBK", 1000); // System.out.println(html); Document doc = Jsoup.parse(html); Elements form = doc.select("a:contains(末页)"); if (!form.isEmpty()) { String keyword = form.get(0).attr("href"); String booksize = keyword.substring(keyword.lastIndexOf(",") + 1, keyword.length() - 1); int size = Integer.parseInt(booksize); System.out.println("一共 " + size + " 本书"); Set<Book> books = queryBooks(html); List<PageGetThread> threadList = new ArrayList<>(); AtomicInteger needGettedPage = new AtomicInteger(2);//需要获取的页码 int lastPage = size / 10 + 1;//最后一页的页码 //开始多线程刷所有页码 for (int threadN = 0; threadN < threadNumber; threadN++) { threadList.add(new PageGetThread(needGettedPage, lastPage)); } for (PageGetThread thread : threadList) { thread.start(); } for (PageGetThread thread : threadList) { try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } threadList.forEach(pageGetThread -> books.addAll(pageGetThread.getThreadBooks())); return books; } return null; } /** * 下载分类下所有图书。 * 所有书籍将直接保存在{@code pathname}目录下,每本书一个文件夹,以书名命名。如同名,则加作者名,如又同名,加书本编号 * * @param pathname 存储路径。书本文件夹所在的上级路径 * @param threadNumber 线程数 * @param errorLogPath 错误日志路径 * @throws IOException 连接失败的错误 */ public void downloadAllBooks(String pathname, int threadNumber, String errorLogPath) throws IOException { checkCookie(); String data = "fenlei=" + this.id + "&mark=all&Page=1&totalnumber=-1"; String Url = NJULib.baseUrl + "/markbook/classifybookview.jsp"; String html = MyHttpRequest.postWithCookie(data, Url, null, cookie, "UTF-8", "GBK", 1000); // System.out.println(html); Document doc = Jsoup.parse(html); Elements form = doc.select("a:contains(末页)"); if (!form.isEmpty()) { String keyword = form.get(0).attr("href"); String booksize = keyword.substring(keyword.lastIndexOf(",") + 1, keyword.length() - 1); int size = Integer.parseInt(booksize); System.out.println(this.getPath()+"一共 " + size + " 本书"); Set<Book> books = queryBooks(html); Set<Book> downloading; downloadBooks(books, pathname, threadNumber, errorLogPath); int lastPage = size / 10 + 1;//最后一页的页码 int index = 1; for (int i = lastPage; i >= 2; i--) { downloading = queryBooks(i); for (Book book : downloading) { if (books.add(book)) { book.download(pathname, threadNumber, errorLogPath); index++; } else { System.out.println("服务器返回了重复书籍,跳过 " + book); } } } System.out.println("去重后共" + books.size() + "书,实际下载了" + (index + 10) + "本书(含失败)"); } } private void downloadBooks(Set<Book> books, String pathname, int threadNumber, String errorLogPath) { for (Book book : books) { book.download(pathname, threadNumber, errorLogPath); } } /** * 获取所有图书列表的线程 */ class PageGetThread extends Thread { Set<Book> books = new HashSet<>(); AtomicInteger needGettedPage; int lastPage; public PageGetThread(AtomicInteger needGettedPage, int lastPage) { this.needGettedPage = needGettedPage; this.lastPage = lastPage; } @Override public void run() { while (true) { int gettingpage = needGettedPage.getAndIncrement(); if (gettingpage <= lastPage) { try { if (gettingpage % 10 == 0) { resetCookie(); } books.addAll(queryBooks(gettingpage)); } catch (IOException e) { e.printStackTrace(); } } else { break; } } } public Set<Book> getThreadBooks() { return books; } } /** * 获取HTML文本中的书籍并根据其分类添加进当前的分类结构 * * @param html 服务器特定页面返回的包含书本信息的HTML文本。 * 服务器多个不同页面返回的包含书本信息的HTML中书本信息相关节点的格式都相似。均可调用本函数 * @return HTML中记录的书本 */ public Set<Book> queryBooks(String html) { Document doc = Jsoup.parse(html); Elements booksliNode = doc.select("li[style]"); return queryBooks(booksliNode); } private Set<Book> queryBooks(Elements booksliNode) { Set<Book> books = new HashSet<>(); for (Element element : booksliNode) { //获取书名和id String name = null, id = null, author = null, publishDate = null, theme = null, detailBookClass = null; BookClass bookBookClass; Elements nameIdNode = element.select("p[class=name]"); if (nameIdNode != null) { name = nameIdNode.text(); Elements idNode = nameIdNode.select("a[onclick]"); if (idNode != null && idNode.size() > 0) { String idOnClick = idNode.get(0).attr("onclick"); int start = idOnClick.indexOf("(") + 1, end = idOnClick.lastIndexOf(","); if (start != 0 && end != -1) { id = idOnClick.substring(start, end); } } } //获取分类 BookClass[] bookClasses = new BookClass[0]; Elements infoNode = element.select("p[class=info]"); if (infoNode != null) { Elements bookInfos = infoNode.select("a"); if (bookInfos != null && bookInfos.size() > 0) { Element terminalCataNode = bookInfos.last(); bookInfos.remove(terminalCataNode); List<BookClass> tmplist = bookInfos.stream() .map(bookInfo -> getBookCata(bookInfo, false)) .filter(Objects::nonNull) .collect(Collectors.toList()); BookClass terminalBookClass = getBookCata(terminalCataNode, true); if (terminalBookClass != null) { tmplist.add(terminalBookClass); } bookClasses = tmplist.toArray(bookClasses); } } bookBookClass = new RootBookClass().link(bookClasses); //获取作者,出版日期,主题词,分类 String info = element.text(); Pattern pattern = Pattern.compile("\\d+\\. (.*) 作者[::](.*) 出版日期[::](\\d+).*?(?:主题词[::](.+))? 分类[::](.*)"); Matcher matcher = pattern.matcher(info); while (matcher.find()) { name = matcher.group(1); author = matcher.group(2); publishDate = matcher.group(3); theme = matcher.group(4); detailBookClass = matcher.group(5); } Pattern minPattern = Pattern.compile(".*(《.*》).*"); Matcher minMatcher = minPattern.matcher(info); while (minMatcher.find()) { name = minMatcher.group(1); } //汇总书本 if (name != null && id != null) { Book book = new Book(id, name, author, publishDate, theme, bookBookClass, detailBookClass); book.setCookie(cookie); books.add(book); if (bookBookClass.isTerminal()) { ((TerminalBookClass) bookBookClass).addBook(book); } else { System.out.println("未获取到分类信息,将不被归档 " + book); } } else { System.out.println("error: " + info); } } return books; } /** * 通过HTML中对应节点获取到书所在分类 * * @param bookInfo 书本信息的HTML节点 * @param isTerminal 是否是终端分类 * @return 书所在分类。如果是终端分类将会返回{@code TerminalBookClass} */ private BookClass getBookCata(Element bookInfo, boolean isTerminal) { String cataName = bookInfo.text(); String href = bookInfo.attr("href"); if (href != null) { int cataIdStart = href.indexOf('=') + 1; if (cataIdStart != 0) { String cataId = href.substring(href.indexOf('=') + 1, href.length()); BookClass tmp = isTerminal ? new TerminalBookClass(cataId) : new BookClass(cataId); tmp.setName(cataName); return tmp; } } return null; } /** * 从服务器查询当前分类下图书的数量。包含所有子分类下的图书 * * @return 当前分类下图书的数量 * @throws IOException 查询失败 */ public int queryBooksSize() throws IOException { checkCookie(); String data = "fenlei=" + this.getId() + "&mark=all&Page=1&totalnumber=-1"; String Url = NJULib.baseUrl + "/markbook/classifybookview.jsp"; String html = MyHttpRequest.postWithCookie(data, Url, null, cookie, "UTF-8", "GBK", 1000); // System.out.println(html); Document doc = Jsoup.parse(html); Elements form = doc.select("input[name=totalnumber]"); if (!form.isEmpty()) { String booksize = form.get(0).attr("value"); return Integer.parseInt(booksize); } return 0; } /** * 检查{@code cookie}如果为null将会更新cookie * * @throws IOException 更新cookie失败 */ private void checkCookie() throws IOException { cookie = (cookie == null) ? NJULib.getSession() : cookie; } /** * 重置{@code cookie} * * @throws IOException 重置cookie失败 */ private void resetCookie() throws IOException { cookie = NJULib.getSession(); } /** * 对当前分类添加子分类 * * @param childBookClasses 顺次路径关系子分类,后一个是前一个的子分类。第一个是当前分类的子分类 * @return 子分类的最后一级分类.若子路径参数为空,则为当前分类 */ public BookClass link(BookClass... childBookClasses) { BookClass currentBookClass = this; for (BookClass bookClass : childBookClasses) { BookClass previois = currentBookClass.addChild(bookClass); if (previois != null) { currentBookClass = previois; } else { bookClass.parent = currentBookClass; currentBookClass = bookClass; } } return currentBookClass; } /** * 获取分类对象所有终端分类下已存储的书籍 * <p> * 不会触发网络请求,只是迭代收集子分类的下已存在的书籍。 * 如要即时从服务器查询书籍,请调用{@link #queryAllBooks()}及其重载 * * @return 该分类下属所有分类的图书集合 */ public Set<Book> getBooks() { return this.getChildren().stream().map(BookClass::getBooks).collect(HashSet::new, Set::addAll, Set::addAll); } /** * 判断两个{@code BookClass}是否是同一个分类。 * 仅根据代号即{@link BookClass#id}来判断 * * @param obj 任意对象 * @return 对象是否是同一个分类 */ @Override public boolean equals(Object obj) { if (!(obj instanceof BookClass)) return false; if (obj == this) return true; return this.id.equals(((BookClass) obj).id); } /** * 获取分类所在的路径。 * 返回可读的{@code String},对二级分类到当前分类顺次所经路径分别调用{@link BookClass#toString()},用"-"分割 * * @return 从二级分类到当前分类顺次所经路径,用"-"分隔分类 */ public String getPath() { Stack<BookClass> parents = new Stack<>(); BookClass bookClass = this; while (bookClass!=null&&!bookClass.isRoot()) { parents.push(bookClass); bookClass = bookClass.getParent(); } StringBuilder sb = new StringBuilder(); if (!parents.isEmpty()) { sb.append(parents.pop().toString()); } while (!parents.isEmpty()) { sb.append("-"); sb.append(parents.pop().toString()); } return sb.toString(); } /** * 用于判断{@link BookClass}对象是不是{@link RootBookClass}的实例 * * @return 是否是根分类 */ public boolean isRoot() { return false; } /** * 返回{@code BookClass}的哈希值。 * 会直接使用用{@link #id}的哈希值 * * @return 哈希值 */ @Override public int hashCode() { return id.hashCode(); } /** * 返回{@code BookClass}的可读字符串描述。 * * @return 格式是 "分类代号(分类名)",如果分类名为null,则格式是"分类代号" */ @Override public String toString() { return this.getId() + (this.getName() == null ? "" : "(" + this.getName() + ")"); } }