【Java】Jsoup 解析HTML報告

emdzz發表於2024-08-02

一、需求背景

有好幾種報告檔案,目前是人肉找報告資訊填到Excel上生成統計資訊

跟使用者交流了下需求和提供的幾個檔案,發現都是html檔案

其實所謂的報告的檔案,就是一些本地可開啟的靜態資源,裡面也有js、img等等

二、方案選型

前面老闆一直說是文件解析,我尋思這不就是寫爬蟲嗎....

因為是在現有系統上加新功能實現,現有系統還是Java做後端服務,所以之前學的Python就不想用了

寫Python還需要單獨起個服務部署起來,Java有JSOUP能用,沒Python那麼好用就是...

三、落地實現

1、JSOUP依賴座標:

<!-- https://mvnrepository.com/artifact/org.jsoup/jsoup -->
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.18.1</version>
</dependency>

2、檔案讀取問題

我發現每種型別的報告檔案的存放方式都不一樣

第一種單HTML檔案:

這種相對簡單,只需要讀取路徑後直接訪問檔案內容即可

String reportFilePath = "C:/Users/Administrator/Desktop/report-type/xxx.html";
String htmlContent = new String(Files.readAllBytes(Paths.get(reportFilePath)), StandardCharsets.UTF_8);
Document doc = Jsoup.parse(htmlContent); 

第二種單Zip壓縮檔案:

單層壓縮,可以透過zipFile的API訪問,取出壓縮條目一個個用條目名稱進行判斷

再透過zipFile開啟讀取流對該條目進行讀取

String targetFile = "index.html";
ZipEntry targetEntry = null;
String reportFilePath = "C:/Users/Administrator/Desktop/report-type/xxxhtml.zip";
ZipFile zipFile = isWinSys() ? new ZipFile(new File(reportFilePath), ZipFile.OPEN_READ, Charset.forName("GBK")) : new ZipFile(reportFilePath);
Enumeration<? extends ZipEntry> zipEntries = zipFile.entries();
while (zipEntries.hasMoreElements()) {
    ZipEntry zipEntry = zipEntries.nextElement();
    boolean isDirectory = zipEntry.isDirectory();
    if (isDirectory) continue;
    String name = zipEntry.getName();
    if (targetFile.equals(name)) {
        targetEntry = zipEntry;
        break;
    }
}
boolean hasFind = Objects.nonNull(targetEntry);
if (!hasFind) return; /* 沒有可讀取的目標檔案 */
InputStream inputStream = zipFile.getInputStream(targetEntry);
String htmlCode = IoUtil.readUtf8(inputStream);
Document doc = Jsoup.parse(htmlCode);

執行完成後記得要釋放資源:

/* 資源釋放 */
inputStream.close();
zipFile.close();

  

第三種多Zip巢狀壓縮檔案:

檔案被壓縮了兩次,要解壓兩邊才可以訪問

1、讀取內嵌的Zip檔案時發現MALFORM報錯,需要根據作業系統設定讀取編碼...

https://blog.csdn.net/qq_25112523/article/details/136060946 

然後在建立ZipFile物件的API加了一個作業系統的判斷

public static boolean isWinSys() {
    String property = System.getProperty("os.name");
    return property.contains("win") || property.contains("Win");
}

2、ZipFile只對單層壓縮有用,如果是巢狀的壓縮檔案就不支援了

這個報告檔案的情況是第一層只有一個條目,所以上傳上來的檔案我只關心裡面只有一個內嵌的壓縮檔案就行

當匹配這個條件交給ZipFile讀取輸入流,轉換成Zip輸入流,否則不處理

可以在下面程式碼看到,對被壓縮的檔案進行inputStream讀取後,要改用ZipInputStream讀取

zipInputStream 等效 zipFile + zipEntries的合體,包含了條目迭代資訊

但是隻有一個getNextEntry方法,只能寫While迴圈不斷判斷下一個條目是否還存在

檔名叫report.html,判斷條目名是否匹配後結束迴圈

再利用IO工具類直接讀取ZipInputStream即可 (getNextEntry方法就是讓ZipInputStream不斷切換到當前條目的引用)

如果要處理複雜情況要在While裡面才能實現的,建議每個條目結束之後呼叫closeEntry方法

String targetSuffix = ".zip";
String targetFile = "report.html";
String reportFilePath = "C:/Users/Administrator/Desktop/report-type/xx_20240729153751.zip";
ZipFile zipFile = isWinSys() ? new ZipFile(new File(reportFilePath), ZipFile.OPEN_READ, Charset.forName("GBK")) : new ZipFile(reportFilePath);
Enumeration<? extends ZipEntry> enumeration = zipFile.entries();
/* 轉換成集合條目,迭代條目不能判斷size */
List<ZipEntry> zipEntrieList = new ArrayList<>();
while (enumeration.hasMoreElements()) {
    ZipEntry zipEntry = enumeration.nextElement();
    zipEntrieList.add(zipEntry);
}
/* 只有1個zip壓縮檔案時才處理 */
if (CollectionUtils.isEmpty(zipEntrieList)) return;
boolean isOnlyOneEntry = zipEntrieList.size() == 1;
boolean anyMatch = zipEntrieList.stream().anyMatch(ze -> ze.getName().endsWith(targetSuffix));
if (!isOnlyOneEntry || !anyMatch) return;
ZipEntry zipEntry = zipEntrieList.get(0);
/* 透過ZipInputStream不斷切換條目找到目標檔案 */
InputStream inputStream = zipFile.getInputStream(zipEntry);
ZipInputStream zipInputStream = new ZipInputStream(inputStream);
/* 在內層中尋找目標檔案 */
ZipEntry reportEntry = zipInputStream.getNextEntry();
while (Objects.nonNull(reportEntry)) {
    String name = reportEntry.getName();
    if (targetFile.equals(name)) break;
    reportEntry = zipInputStream.getNextEntry();
}
String htmlCode = IoUtil.readUtf8(zipInputStream);
Document doc = Jsoup.parse(htmlCode);

同樣這裡也需要釋放資源:

/* 資源釋放 */
zipInputStream.close();
inputStream.close();
zipFile.close();

  

3、常見查詢API使用

一、常見API方法

下班到家才反應過來ownText是元素自己的文字內容,過濾掉其他巢狀的元素文字

也可以直接使用cssQuery

doc.select("table.y-report-ui-report-info-grid")

  

二、使用兄弟元素查詢對應關係

有一個特殊的情況就是有些元素按文件結構應該是一個逐層關聯的結構

先有A,然後B在A裡面,C又在B裡面這樣

但是這個是攤開來的結構,A -> B -> C -> D,元素id和類名也沒用直接關係,這樣是很難構建關聯的

只能透過元素的順序推斷結構:

1、獲取當前ip標題元素和下一個ip標題元素的兄弟元素下標值

2、將idp元素的兄弟元素下標值取出

3、比較idp元素是否在兩者之間,如果為是表示idp元素屬於第一個ip標題元素

三、父子元素操作獲取兄弟元素

報告明細列表,發現標題是xx名稱,xx等級摘要資訊,點選詳情是把下一行展示出來

然後在下一行的tr中列出xx的全部資訊

使用siblingIndex不準確,元素是動態的,可以第一張表10個,第二章表20個這樣

所以在表格讀取的時候改用 parent() + child()方式讀取

在選取表格所有摘要行後,透過父元素的indexOf方法獲取當前摘要行的下標

再加一就是下一個明細行的下表了

同樣還可以透過當前元素的child方法直接去第N個子元素

這個方式相比select方法不用從元素集合中獲取,確定是唯一的一個元素

/* 2、讀取【漏洞分佈】資訊 */
Element vulnTable = doc.getElementById("vuln_distribution");
Element vulnTableBody = vulnTable.child(1);
Elements allTrList = vulnTableBody.children();
Elements vulnTitleTrList = vulnTable.select("tr[style='cursor:pointer;']");
for (Element vrTr : vulnTitleTrList) {
    /* 2-1、漏洞名稱 */
    String vt = vrTr.child(1).text();
    int vrTrIdx = allTrList.indexOf(vrTr);
    Element vrDetailTr = allTrList.get(vrTrIdx + 1);
    Element vrDetailTableBody = vrDetailTr.child(1).child(0).child(0);
    /* 2-2、漏洞主機 */
    String ipHosts = vrDetailTableBody.child(0).child(1).text();
    ipHosts = ipHosts.replaceAll("&nbsp", "").replaceAll(" 點選檢視詳情;", "");
    /* 2-3、漏洞描述 */
    String vulnDesc = vrDetailTableBody.child(1).child(1).text();
    /* 2-4、威脅分值 */
    String vulnTag = vrDetailTableBody.child(3).child(1).text();
    String format = StrFormatter.format("reportTime: {}, ip: {}, name: {}, tag: {} desc: {}, ", date, ipHosts, vt, vulnTag, vulnDesc);
    System.out.println(format);
}

  

相關文章