首發(fā)地址: https://mp.weixin.qq.com/s/w6v3RhqN0hJlWYlqTzGCxA
前言
之前在PC微信逆向) 定位微信瀏覽器打開鏈接的call提過要寫一個(gè)保存公眾號(hào)歷史文章的工具锅纺。這篇文章先寫一個(gè)將文章保存成pdf和html的工具礼华,后面再補(bǔ)充一個(gè)采集歷史的工具,搭配使用就能保存所有歷史文章到本地乏梁。
如果是在瀏覽器打開文章芝囤,想保存成pdf和html很簡(jiǎn)單瓮具,右鍵打印(pdf)和另存為(html)就可以了荧飞。想在程序里實(shí)現(xiàn)則需要一些自動(dòng)化工具,例如playwright名党、puppeteer等叹阔,但這些都沒有移植到aardio。
cdp
先科普一個(gè)知識(shí):大部分自動(dòng)化工具都是基于chromium內(nèi)核瀏覽器自帶的一個(gè)叫Chrome DevTools Protocol
[1]的協(xié)議(后面簡(jiǎn)稱cdp)传睹,它涵蓋了對(duì)谷歌瀏覽器的所有自動(dòng)化操作耳幢。
cdp協(xié)議使用jsonrpc和谷歌瀏覽器通信,所以完全可以在aardio也實(shí)現(xiàn)一個(gè)類似drissionpage的庫欧啤,但是工程量不小睛藻,我沒那么多時(shí)間去實(shí)現(xiàn)。所以只在用到哪部分的時(shí)候完善哪部分接口邢隧,不會(huì)去完整實(shí)現(xiàn)一個(gè)drissionpage店印。
用到的cdp接口
保存成html
cdp協(xié)議里并沒有直接獲取頁面html的接口,但是可以通過獲取頁面document.body.outerHTML
的值來得到倒慧。而獲取該值則是通過Runtime.evaluate
[2]接口執(zhí)行js表達(dá)式并返回結(jié)果按摘。
不過這樣保存的html打開之后,會(huì)顯示一直轉(zhuǎn)圈纫谅,并且圖片無法加載炫贤。這是因?yàn)橛行﹫D片用的相對(duì)鏈接,解決方法就是替換相對(duì)鏈接為絕對(duì)鏈接付秕。不過我更推薦保存成mhtml兰珍,這樣圖片就會(huì)被嵌入到html里,不需要從網(wǎng)絡(luò)加載询吴。
保存成mhtml
cdp協(xié)議里保存成mhtml的接口是Page.captureSnapshot
[3]
保存成pdf
接口是Page.printToPDF
[4]
簡(jiǎn)單使用
aardio其實(shí)提供了cdp協(xié)議的封裝庫web.socket.chrome
掠河,用法可以在案例里搜索這個(gè)。
保存成mhtml
import win.ui;
import console
import web.view;
import web.socket.chrome;
/*DSG{{*/
var winform = win.form(text="測(cè)試";right=759;bottom=469;bgcolor=16777215)
winform.add()
/*}}*/
var wb = web.view(winform,,"--remote-debugging-port=29999");
winform.text = "正在打開網(wǎng)頁猛计,請(qǐng)稍候 ……"
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();
雖然保存了口柳,但是圖片并沒有顯示,應(yīng)該是圖片還沒加載就已經(jīng)開始保存了有滑,并且有些圖片只有滑動(dòng)到底部時(shí)才會(huì)加載跃闹。所以還需要先下拉到底部,讓頁面把圖片全部加載出來再進(jìn)行保存毛好。
異步改同步
這是個(gè)異步庫望艺,上面的寫法看起來不太順眼,可以將它稍微封裝一下改為同步庫使用肌访。
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;
}
這樣調(diào)用就順眼多了找默,當(dāng)然習(xí)慣了異步的話也可以不改。
var result = callWait(ws, "Page.captureSnapshot", {});
string.save("示例.mhtml", result.data)
滑動(dòng)到底部
滑動(dòng)操作用JavaScript比cdp接口要簡(jiǎn)單的多吼驶,所以先找gpt寫一段JavaScript滑動(dòng)到底部的代碼(需要多調(diào)教幾次惩激,最初版本肯定是有錯(cuò)誤的)店煞。
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
}
// 開始滑動(dòng)
callWait(ws, "Runtime.evaluate", params);
// 有時(shí)候滑動(dòng)還未結(jié)束,上面的代碼就返回了风钻,所以繼續(xù)等待
..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)
}
封裝成庫
全部放出來代碼會(huì)太多顷蟀,所以將代碼封裝成了庫(cdpdriver),放到了之前寫的aardio教程) 搭建自己的擴(kuò)展庫倉庫里骡技,有興趣的可以去github自己看怎么實(shí)現(xiàn)的鸣个。
封裝的庫使用示例如下:
import cdpdriver;
import web.view;
import win.ui;
import console
/*DSG{{*/
var winform = win.form(text="cdp協(xié)議";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事件觸發(fā)表示頁面加載完成
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("測(cè)試.mhtml", mhtml)
}
}
initWebView()
winform.show();
win.loopMessage();
這樣保存的mhtml圖片顯示也正常
pdf也是正常的
嚴(yán)重bug
當(dāng)某個(gè)網(wǎng)頁的圖片特別多的時(shí)候,保存的mhtml文件特別大的時(shí)候(比如八九十兆)布朦,這時(shí)候控制臺(tái)就會(huì)出現(xiàn)no enough memory
的錯(cuò)誤囤萤,經(jīng)過多天的排查,沒有找到具體原因是趴,不過我猜測(cè)是aardio異步傳輸數(shù)據(jù)時(shí)涛舍,申請(qǐng)的內(nèi)存空間小于這個(gè)文件大小,所以當(dāng)傳輸文件的數(shù)據(jù)時(shí)就會(huì)出錯(cuò)唆途。
解決方法
這個(gè)解決不了只能不用這個(gè)異步庫富雅,自己基于官方擴(kuò)展庫里的hpsocket實(shí)現(xiàn)一個(gè)jsonrpc。
但是官方擴(kuò)展庫的hpsocket使用的dll還是2017年的版本窘哈,為了避免之前版本有未修復(fù)的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協(xié)議";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();
// 計(jì)算網(wǎng)頁圖片的數(shù)量
var imgCount = cdpClient.runJsCode('document.querySelectorAll("#img-content img").length;')
// 如果獲取數(shù)量失敗滚婉,則默認(rèn)是40
imgCount := 40;
// 每張圖片會(huì)多等待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("測(cè)試.pdf", pdfBytes);
logger.info("保存pdf成功帅刀,路徑:%s", io.fullpath("測(cè)試.pdf"));
}
if(mhtml) {
..string.save("測(cè)試.mhtml", mhtml);
logger.info("保存mhtml成功让腹,路徑:%s", io.fullpath("測(cè)試.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
麻煩的多扣溺,因?yàn)樗腔诙嗑€程的骇窍,所以正常情況下推薦使用web.socket.chrome
,只有當(dāng)你遇到不能使用的情況锥余,才換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