最近工作上稍微閑點,這一周利用下班時間寫了一個小工具,其實功能挺簡單但也小折騰了會。
工具名稱:Simple Send to Kindle
Github地址:https://github.com/zhanjindong/SimpleSendToKindle
功能:Windows下一個簡單的將網頁內容推送到Kindle的工具。
寫這個工具的是滿足自己的需求。自從買了Kindle paperwhite 2,它就成了我使用率最高的一個電子設備。相信很多Kindle擁有者和我一樣都有這樣一個需求:就是白天網上看到了一些好文章沒時間看,就想把它推送到Kindle上,晚上睡覺前躺在床上慢慢看。之前我一直用的是一個叫KindleMii的工具,但是發現經常推送的內容圖片丟失了,Chrome應用商店里有一個叫做Send to Kindle的工具但是裝了之后不知道什么原因用不了,於是我就想不如自己動手寫一個,名字就叫Simple Send to Kindle。
原理
原理很簡單,就是通過Chrome擴展程序將網頁鏈接發送給本地的一個Java寫的程序,這個程序將網頁內容下載下來並轉換為Kindle的mobi格式,然后再通過kindle的郵箱發送給Kindle設備。
工具的核心功能是利用Amazon提供的一個叫kindlegen的程序生成mobi文件,大家也可以離線使用這個工具將網頁內容生成各種Kindle支持的格式,另外一個核心是Chrome擴展和本地程序的Native Messaging,這個浪費了我挺長時間,后面會簡單介紹下。
如何使用
1、用mvn assembly打包,打包后目錄如下:
2、工具可以放到任何地方,然后執行setup.bat這個腳本。
3、安裝Chrome擴展。在Chrome里輸入chrome://extension就可以進入擴展管理:點加載正在開發的擴展程序,選擇ext下的Chrome目錄就可以以開發者模式加載擴展程序了,可以看到每個擴展都有一個唯一標識ID,這個后面配置會用到。
加載成功就可以在瀏覽器地址欄右邊看到這個logo了:
4、工具已經安裝成功了下面進行一些簡單配置就可以了:
1)打開SimpleSendToKindle.json這個文件:將allowed_origins里面的內容修改為上面Chrome擴展的ID。
2)sstk.properties里面是一些工具的通用配置:
#整個服務的超時時間 sstk.service.timeout = 120000 #網頁內容或圖片的下載超時時間 sstk.download.timeout = 15000 #是否刪除臨時目錄 sstk.download.deleteTmpDir = false mail.smtp.starttls.enable=true mail.smtp.socketFactory.port=25 mail.smtp.host=smtp.126.com mail.host=smtp.126.com mail.smtp.auth=true mail.transport.protocol=smtp mail.userName=XXX mail.password=iflytek mail.from=XXX@126.com mail.to=XXX@kindle.cn #debug sstk.debug.sendMail = false
主要配置的就是郵箱這塊,mail.to配置是你的Kindle郵箱,mail.from是用來發送的郵箱,我這里用的是126,其他郵箱也都支持smtp,有Kindle的同學都知道要想Kindle收到郵件發送的內容必須將發送油箱添加到Amazon認可的郵箱列表中。
都配置好后看到你想要推送的頁面,只要輕輕點擊下就Ok了。
稍等片刻,查看你的Kindle,效果如下:
遇到的一些問題
工具雖然簡單,但是從思路到成型,過程也遇到了一些問題,這里跟大家分享下,有興趣的同學可以接着往下看。
實現思路
有了想法后首先要想的就是實現思路,一開始想用JavaScript寫,最后只要安裝一個Chrome擴展程序就可以了,這樣肯定是Simple的,但是最后還是放棄這個想法,一來我對JS基本不會,二來寫這個工具的目的是為了滿足自己的需求,怎么快怎么來,什么技術熟悉就用什么,所以最后還是決定用Chrome擴展和Java程序通信這種方式。但這過程發現了一些很有用的工具,我在最后會推薦給大家。
Chrome擴展開發
我一直用的都是chrome,所以想到了開發Chrome下的插件(Chrome下叫Extension擴展)。那首先要解決的就是如何開發Chrome插件?開發chrome擴展很簡單,官方有一個入門例子非常簡單,一看就懂http://chrome.liuyixi.com/getstarted.html。這里推薦園子里的一篇文章:Chrome插件(Extensions)開發攻略。
Chrome擴展和本地程序通信
官方術語叫做Native Messaging,具體技術細節這里不啰嗦了,有興趣的同學可以網上搜下,這里指簡單介紹下。chrome擴展在Windows下是通過HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\這個注冊表下面的內容和一個.json的清單文件來找到你的Native App的。上面的setup.bat就是用來寫入注冊表的,SimpleSendToKind.json就是清單文件:
@echo off reg add HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\so.zjd.sstk /ve /t REG_SZ /d %~dp0\SimpleSendToKindle.json /f
setup.bat將so.zjd.sstk這個“程序”注冊到chrome關心的注冊表下,Chrome通過它找到標識應用程序信息的清單文件:
{ "name":"so.zjd.sstk", "description":"Simple Send to Kindle(by zjd.so)", "path":"startup.exe",
"type":"stdio", "allowed_origins":[ "chrome-extension://jnihbngmnjbmchfhcdfabofamnfcljaf/" ] }
path是本地程序的路徑,除了注意程序的權限問題外,還要注意這里path里面如果有路徑分隔符必須是雙斜杠“//”。
Chrome是通過系統的標准輸入輸出和本地程序進行通信,具體協議如下:
Chrome 瀏覽器在單獨的進程中啟動每一個原生消息通信宿主,並使用標准輸入(
stdin
)與標准輸出(stdout
)與之通信。向兩個方向發送消息時使用相同的格式:每一條消息使用 JSON 序列化,以 UTF-8 編碼,並在前面附加 32 位的消息長度(使用本機字節順序)。
協議其實很簡單,但是這塊卻浪費了我好長時間,我用Java死活無法讀取Chrome寫入標准輸入的內容,總是報下面的錯誤:
一開始懷疑自己的寫的代碼有問題,網上搜了半天有說是JDK的問題,我重裝還是不行。后來我發現Chrome傳給程序其實有兩個參數,一個windwos的句柄,一個Chrome擴展的ID:
arg 0:--parent-window=3349886 arg 1:chrome-extension://oojaanpmaapemaihjbebgojmblljbhhh/
所以我就想Java能不能直接從Windows句柄讀數據,因為Java確實提供了一個FileDescriptor類,但折騰了半天發現原生的Java並不支持這么干。最后沒辦法下,想出了非常丑陋的解決辦法,利用C#來做下中轉,所以才多了個startup.exe,C#代碼寫的很順利,這也讓我對Java是累感不愛啊。

1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Text; 5 using System.IO; 6 using System.Diagnostics; 7
8 namespace Startup 9 { 10 class Program 11 { 12 static void Main(string[] args) 13 { 14 try
15 { 16 if (!Directory.Exists(System.AppDomain.CurrentDomain.BaseDirectory + "\\log")) 17 { 18 Directory.CreateDirectory(System.AppDomain.CurrentDomain.BaseDirectory + "\\log"); 19 } 20
21 if (args.Length == 0) 22 { 23 WriteStandardStreamOut("Missing parameter."); 24 Log2File("Missing parameter."); 25 return; 26 } 27
28 string url = ReadStandardStreamIn(); 29 Log2File("Running SimpleSendToKindle.jar with url:" + url); 30 string ret = RunJar(url); 31 Log2File("Completed with return msg:" + ret); 32 WriteStandardStreamOut("{\"text\":\"" + ret + "\"}"); 33 } 34 catch (Exception ex) 35 { 36 Log2File("Error:" + ex.ToString()); 37 WriteStandardStreamOut("{\"text\":\"" + "Error." + ex.Message + "\"}"); 38 } 39 } 40
41 static string RunJar(string arg) 42 { 43 ProcessStartInfo startInfo = new ProcessStartInfo() 44 { 45 WorkingDirectory = System.AppDomain.CurrentDomain.BaseDirectory, 46 UseShellExecute = false,//要重定向 IO 流,Process 對象必須將 UseShellExecute 屬性設置為 False。
47 CreateNoWindow = true, 48 RedirectStandardOutput = true, 49 //RedirectStandardInput = false,
50 WindowStyle = ProcessWindowStyle.Normal, 51 FileName = "java.exe", 52 Arguments = @" -Dfile.encoding=utf-8 -jar SimpleSendToKindle.jar " + arg, 53 }; 54 //啟動進程
55 using (Process process = Process.Start(startInfo)) 56 { 57 process.Start(); 58 //process.WaitForExit();
59 using (StreamReader reader = process.StandardOutput) 60 { 61 return reader.ReadToEnd(); 62 } 63 } 64 } 65
66 static void Log2File(string s) 67 { 68 FileStream fs = new FileStream(System.AppDomain.CurrentDomain.BaseDirectory + @"log/startup.log", FileMode.Append); 69 StreamWriter sw = new StreamWriter(fs, Encoding.UTF8); 70 sw.WriteLine(s); 71 sw.Close(); 72 fs.Close(); 73 } 74
75 static string ReadStandardStreamIn() 76 { 77 using (Stream stdin = Console.OpenStandardInput()) 78 { 79 int length = 0; 80 byte[] bytes = new byte[4]; 81 stdin.Read(bytes, 0, 4); 82 length = System.BitConverter.ToInt32(bytes, 0); 83
84 byte[] msgBytes = new byte[length]; 85 stdin.Read(msgBytes, 0, length); 86
87 string decodeMsg = Microsoft.JScript.GlobalObject.decodeURI(System.Text.Encoding.UTF8.GetString(msgBytes)); 88 return decodeMsg; 89 } 90 } 91
92 static void WriteStandardStreamOut(string msg) 93 { 94 int length = msg.Length; 95 byte[] lenBytes = System.BitConverter.GetBytes(length); 96 byte[] msgBytes = System.Text.Encoding.UTF8.GetBytes(msg); 97 byte[] wrapBytes = new byte[4 + length]; 98 Array.Copy(lenBytes, 0, wrapBytes, 0, 4); 99 Array.Copy(msgBytes, 0, wrapBytes, 4, length); 100
101 using (Stream stdout = Console.OpenStandardOutput()) 102 { 103 stdout.Write(wrapBytes, 0, wrapBytes.Length); 104 } 105 } 106 } 107 }
Chrome擴展獲取當前頁面的url
園子里那個例子里是在content_script.js里用document.URL,但是我發現這有個問題,每次必須重新加載頁面,不然這個值好像全局就一個。發現用chrome.tabs.getSelected這個事件監聽更好些:

chrome.tabs.getSelected(null,function(tab) { var port = null; var nativeHostName = "so.zjd.sstk"; port = chrome.runtime.connectNative(nativeHostName); port.onMessage.addListener(function(msg) { //console.log("Received " + msg);
$("#message").text(msg.text); }); port.onDisconnect.addListener(function onDisconnected(){ //console.log("connetct native host failure:" + chrome.runtime.lastError.message);
port = null; //$("#message").text("Finished!");
}); port.postMessage(encodeURI(tab.url)) });
圖片解析
其實右鍵將網頁另存為為html后就能利用kindlegen生成mobi文件了,或者利用Amazon的郵箱服務直接將html文件發送給Kindle,也能自動轉換成mobi。但是之所以要寫這個工具的原因就是kindlegen也好,kindle郵箱服務也好都不會去主動下載頁面里的圖片,kindlegen需要你將頁面里圖片或其他資源的地址轉換成相對路徑,然后將資源統一放在一個文件家里。
所以處理也很簡單解析頁面img元素內容,自己將圖片下載下來然后將src替換成相對路徑就OK了,需要注意的就是網頁圖片引用的幾種方式:http://www.test.com/dir1/dir2/test.html
./images/mem/figure9.png → http://www.test.com/dir1/dir2/images/mem/figure9.png
images/mem/figure9.png → http://www.test.com/dir1/dir2/images/mem/figure9.png
/images/mem/figure9.png → http://www.test.com/images/mem/figure9.png
../../images/mem/figure9.png → http://www.test.com/figure.png
.表示當前目錄
..表示上級目錄
代碼大致如下:

private String processRelativeUrl(String url) { if (url.startsWith("http://")) { return url; } String pageUrl = this.page.getUrl(); int relative = 0; int index = 0; if (url.startsWith("/")) { relative = -1; } else { while (true) { index = 0; if (url.startsWith("./")) {// 當前目錄 index = url.indexOf("./"); url = url.substring(index + 2); continue; } else if (url.startsWith("../")) {// 上級目錄 relative++; index = url.indexOf("../"); url = url.substring(index + 3); continue; } else {// 當前目錄 break; } } } if (relative == -1) { index = pageUrl.indexOf('/', 7); pageUrl = pageUrl.substring(0, index); url = url.substring(1); } else { for (int i = 0; i <= relative; i++) { index = pageUrl.lastIndexOf("/"); if (index == -1) { break; } pageUrl = pageUrl.substring(0, index); } } url = pageUrl + "/" + url; return url; }
本來是打算也處理CSS的,結果發現CSS反而會導致生成的mobi格式錯亂就算了。
頁面亂碼
有的網頁的meta元素並不規范會導致kindlegen生成的mobi文件亂碼,比如:
<meta charset="UTF-8">
需要處理下:
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
一些網站防止惡意抓取的問題
有些網站的頁面為了防止網絡爬蟲惡意抓取內容會對HTTP請求的User-Agent進行簡單驗證,這種情況簡單模擬下瀏覽器的UA就可以繞過了,這也說明了惡意的抓取確實很難杜絕,前幾天園子里好像還有人提到這個。這里有個疑問:到底什么樣的行為算惡意抓取,就我本人來說肯定不會有任何惡意。
存在的問題
寫的比較匆忙,還存在很多問題:
1、Chrome插件沒界面、沒用戶體驗,只是為了實現功能;
2、需要C#程序來做中轉,這個太惡心了,結果工具一點也不simple;
3、有的中文網頁會導致生成的mobi文件亂碼,肯定是網頁編碼方便的問題,有時間再看看;
4、生成的mobi文件比較大,可以考慮對內容進行裁剪;
5、不支持將頁面選中的內容推送到Kindle;
6、如果頁面有代碼或排版不好,顯示比較亂,可讀性比較差;
7、未考慮Kindle不支持的圖片格式,其實大部分情況就哪幾種圖片;
8、Linux平台支持,其實kindlegen有linux下的版本,Chrome擴展本身在什么平台下都能用。
另外才關注開源沒多久,Github上提交的代碼質量有待提高。
一些資源
前面提到寫這個工具的過程中其實發掘了一些很不錯的工具和服務,這里推薦給大家:
- KDP(Amazon Kindle Direct Publishing):亞馬遜提供的一個服務。
- HTML-to-MOBI:一個在線的將網頁轉換成mobi文件的服務,但是好像圖片處理也有問題。
- 用JS將markdown轉成mobi,epub等電子書格式。
- Java mobi metadata editor:一個小工具可以用來編輯mobi的元數據。
- kindle book development tool:貌似是一個收費的工具。
- Calibre:一個非常強大的免費電子書管理和生成工具,推薦這篇文章抓取網頁內容生成Kindle電子書。
- RssToMobiService Github上一個抓取RSS生成mobi文件發送到Kindle的工具,很不錯的。
寫在最后
今天寫完才發現,原來Amazon官方就有一個插件叫Send to Kindle,而且支持各種瀏覽器,很好很強大,需要的同學直接用官方的吧,這么晚碼字很辛苦,沒有功勞也有苦勞,如果覺得不錯給個推薦吧~
寫這個工具最大的收獲就是:有想法就去做,just do it!