aardio實戰篇) 下載微信公眾號文章為pdf和html

Python成长路發表於2024-06-17

首發地址: https://mp.weixin.qq.com/s/w6v3RhqN0hJlWYlqTzGCxA

前言

之前在PC微信逆向) 定位微信瀏覽器開啟連結的call提過要寫一個儲存公眾號歷史文章的工具。這篇文章先寫一個將文章儲存成pdf和html的工具,後面再補充一個採集歷史的工具,搭配使用就能儲存所有歷史文章到本地。

如果是在瀏覽器開啟文章,想儲存成pdf和html很簡單,右鍵列印(pdf)和另存為(html)就可以了。想在程式裡實現則需要一些自動化工具,例如playwright、puppeteer等,但這些都沒有移植到aardio。

cdp

先科普一個知識:大部分自動化工具都是基於chromium核心瀏覽器自帶的一個叫Chrome DevTools Protocol[1]的協議(後面簡稱cdp),它涵蓋了對谷歌瀏覽器的所有自動化操作。

cdp協議使用jsonrpc和谷歌瀏覽器通訊,所以完全可以在aardio也實現一個類似drissionpage的庫,但是工程量不小,我沒那麼多時間去實現。所以只在用到哪部分的時候完善哪部分介面,不會去完整實現一個drissionpage。

用到的cdp介面

儲存成html

cdp協議裡並沒有直接獲取頁面html的介面,但是可以透過獲取頁面document.body.outerHTML的值來得到。而獲取該值則是透過Runtime.evaluate[2]介面執行js表示式並返回結果。

不過這樣儲存的html開啟之後,會顯示一直轉圈,並且圖片無法載入。這是因為有些圖片用的相對連結,解決方法就是替換相對連結為絕對連結。不過我更推薦儲存成mhtml,這樣圖片就會被嵌入到html裡,不需要從網路載入。

儲存成mhtml

cdp協議裡儲存成mhtml的介面是Page.captureSnapshot[3]

儲存成pdf

介面是Page.printToPDF[4]

簡單使用

aardio其實提供了cdp協議的封裝庫web.socket.chrome,用法可以在案例裡搜尋這個。

儲存成mhtml

import win.ui;
import console
import web.view;
import web.socket.chrome;
/*DSG{{*/
var winform = win.form(text="測試";right=759;bottom=469;bgcolor=16777215)
winform.add()
/*}}*/

var wb = web.view(winform,,"--remote-debugging-port=29999");
winform.text = "正在開啟網頁,請稍候 ……"
winform.show();

var ws = wb.openRemoteDebugging();
 
ws.Page.navigate(
    url = "https://mp.weixin.qq.com/s/Nik8fBF3hxH5FPMGNx3JFw";
);

wb.wait("Nik8fBF3hxH5FPMGNx3JFw");
win.delay(3000)
import crypt;
ws.Page.captureSnapshot().end = function(result,err){
   if(result[["data"]]){
       string.save("示例.mhtml", result.data)
       winform.text = "儲存mhtml成功"
   } 
} 

win.loopMessage();

雖然儲存了,但是圖片並沒有顯示,應該是圖片還沒載入就已經開始儲存了,並且有些圖片只有滑動到底部時才會載入。所以還需要先下拉到底部,讓頁面把圖片全部載入出來再進行儲存。

非同步改同步

這是個非同步庫,上面的寫法看起來不太順眼,可以將它稍微封裝一下改為同步庫使用。

callWait = function(ws, method,params,timeout,interval){
	if(!ws) return;
	var done = null;
	var t = ..string.split(method,".");
	var func = ws;
	for(i=1;#t;1){
		func = func[t[i]];
	}
	var result;
	func(params).end = function(r,err){
		if(!err) {
			done = true;
			result = r;
		}
	};
	..win.wait(lambda() done,winform,timeout:15000,interval);
	return result;
}

這樣呼叫就順眼多了,當然習慣了非同步的話也可以不改。

var result = callWait(ws, "Page.captureSnapshot", {});
string.save("示例.mhtml", result.data)

滑動到底部

滑動操作用JavaScript比cdp介面要簡單的多,所以先找gpt寫一段JavaScript滑動到底部的程式碼(需要多調教幾次,最初版本肯定是有錯誤的)。

scrollPageBottom = function(ws){
	..win.delay(1000);
	var scrollToEnd = `(async function scrollPage() {
    	return new Promise(async (resolve) => {
        	var distance = 500; 
        	var count = 0;
        	window.scrollTo(0, 0);
        	window.scrollTo(0, 0);
        	var scroll = async () => {
            	var lastScrollTop = document.documentElement.scrollTop;
            	window.scrollBy(lastScrollTop, distance);
            	await new Promise(r => setTimeout(r, 500)); 
            	var newScrollTop = document.documentElement.scrollTop;
            	var scrollHeight = document.body.scrollHeight;
            	console.log(lastScrollTop, newScrollTop, scrollHeight);
            	if(lastScrollTop === newScrollTop) count += 1;
            	if ((lastScrollTop === newScrollTop && newScrollTop/scrollHeight > 0.8) || count > 2) {
                	resolve(); 
            	} else {
                	await scroll(); 
            	}
        	};
        	await scroll();
    	});
	})();`;
	var params = {
		"expression": scrollToEnd,
		"awaitPromise": true,
		"returnByValue": true
	}
	// 開始滑動
	callWait(ws, "Runtime.evaluate", params);
	// 有時候滑動還未結束,上面的程式碼就返回了,所以繼續等待
	..win.wait(function(){
		var r= callWait(ws, "Runtime.evaluate", {
			expression="document.documentElement.scrollTop/document.body.scrollHeight > 0.8";
			awaitPromise=true;
			returnByValue=true
		});
		return r;
	},,15000,500)
}

封裝成庫

全部放出來程式碼會太多,所以將程式碼封裝成了庫(cdpdriver),放到了之前寫的aardio教程) 搭建自己的擴充套件庫倉庫裡,有興趣的可以去github自己看怎麼實現的。

封裝的庫使用示例如下:

import cdpdriver;
import web.view;
import win.ui;
import console
/*DSG{{*/
var winform = win.form(text="cdp協議";right=759;bottom=469)
winform.add()
/*}}*/

var initWebView = function(){
	var cmdArgs = `--remote-debugging-port=29999`;
	winform.webView = web.view(winform,,cmdArgs);
	if(!_STUDIO_INVOKED) winform.webView.enableDevTools(false);
	winform.show();
	
	winform.stateTable = {
		pageReady=null;//頁面載入完成
	}
	var ws = winform.webView.openRemoteDebugging();
	var cdpClient = cdpdriver(ws);
	// 啟用Page事件
	ws.Page.enable();
	// Page.domContentEventFired和Page.loadEventFired事件觸發表示頁面載入完成
	ws.on("Page.domContentEventFired",function(param){
		winform.stateTable.pageReady = true;
    })
	ws.on("Page.loadEventFired",function(param){
		winform.stateTable.pageReady = true;
    })
	winform.stateTable.pageReady = null;
	var url = "https://mp.weixin.qq.com/s/Nik8fBF3hxH5FPMGNx3JFw";
	winform.webView.go(url);
	win.wait(lambda() winform.stateTable.pageReady, winform.hwnd, 15000, 50);  
	win.delay(1000) 
	if(winform.stateTable.pageReady){
		cdpClient.scrollPageBottom();
	    var mhtml = cdpClient.outerMHTML;
	    string.save("測試.mhtml", mhtml)
	}
}

initWebView()

winform.show();
win.loopMessage();

這樣儲存的mhtml圖片顯示也正常

pdf也是正常的

嚴重bug

當某個網頁的圖片特別多的時候,儲存的mhtml檔案特別大的時候(比如八九十兆),這時候控制檯就會出現no enough memory的錯誤,經過多天的排查,沒有找到具體原因,不過我猜測是aardio非同步傳輸資料時,申請的記憶體空間小於這個檔案大小,所以當傳輸檔案的資料時就會出錯。

解決方法

這個解決不了只能不用這個非同步庫,自己基於官方擴充套件庫裡的hpsocket實現一個jsonrpc。

但是官方擴充套件庫的hpsocket使用的dll還是2017年的版本,為了避免之前版本有未修復的bug,去github更新一下hpsocket的dll。

hpsocket的dll下載地址: https://github.com/ldcsaa/HP-Socket/releases

hpsocket封裝後的使用案例

import win.ui;
import web.view;
/*DSG{{*/
mainForm = win.form(text="hpsocket cdp協議";right=757;bottom=467)
mainForm.add()
/*}}*/

var threadMain = function(debugPort){
	import win;
	import cdpdriver.hpcdp;
	import cdpdriver.jsonrpc;
	import kilogging;
	
	var logger = kilogging();
	..cdpdriver.jsonrpc.waitDebuggingPages(debugPort);
	var wsClient = ..cdpdriver.jsonrpc();
	wsClient.connect(debugPort);
	wsClient.send("Page.enable");
	wsClient.on("Page.domContentEventFired", function(){
		..thread.set("pageReady" + owner.guid, true);
	})
	wsClient.on("Page.loadEventFired", function(){
		..thread.set("pageReady" + owner.guid, true);
	})
	var cdpClient = ..cdpdriver.hpcdp(wsClient);
	var url = "https://mp.weixin.qq.com/s/Nik8fBF3hxH5FPMGNx3JFw";
	var pageReadyFlag = "pageReady" + wsClient.guid;
	..thread.set(pageReadyFlag, null);
	logger.info("開始下載 (%s) pdf和html", url);
	wsClient.send("Page.navigate",{"url":url})
	win.wait(function(){
		return thread.get(pageReadyFlag);
	},, 10000, 100);
	if(!thread.get(pageReadyFlag)) {
		logger.info("頁面(%s)訪問失敗", url);
		return;
	}
	cdpClient.scrollPageBottom();
	// 計算網頁圖片的數量
	var imgCount = cdpClient.runJsCode('document.querySelectorAll("#img-content img").length;')
	// 如果獲取數量失敗,則預設是40
	imgCount := 40;
	// 每張圖片會多等待300毫秒
	..win.delay(imgCount * 300);
	var mhtmlData = cdpClient.getOuterMHTML();
	var mhtml = mhtmlData ? mhtmlData.data;
	var pdfData = cdpClient.getPdf();
	var pdf = pdfData ? pdfData.data;
	logger.info("獲取到的檔案大小,pdf(%s), mhtml(%s)",tostring(#pdf), tostring(#mhtml));
	if(pdf) {
		var pdfBytes = ..crypt.bin.decodeBase64(pdf);
		..string.save("測試.pdf", pdfBytes);
		logger.info("儲存pdf成功,路徑:%s", io.fullpath("測試.pdf"));
	}
	if(mhtml) {
		..string.save("測試.mhtml", mhtml);
		logger.info("儲存mhtml成功,路徑:%s", io.fullpath("測試.mhtml"));
	}	
}

var initWebView = function(){
	var cmdArgs = `--remote-debugging-port=29999`;
	mainForm.webView = web.view(mainForm,,cmdArgs);
	mainForm.show();
	
	var debugPort = mainForm.webView.remoteDebuggingPort;
	thread.invoke(threadMain,debugPort)	
}

initWebView()

mainForm.show();
return win.loopMessage();

很明顯,hpsocket寫程式碼要比web.socket.chrome麻煩的多,因為它是基於多執行緒的,所以正常情況下推薦使用web.socket.chrome,只有當你遇到不能使用的情況,才換hpsocket

引用連結

  • [1] https://chromedevtools.github.io/devtools-protocol/
  • [2] https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#method-evaluate
  • [3] https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureSnapshot
  • [4] https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章