首發地址: 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 釋出!