2022年5月29日 星期日

json 裁剪/轉檔 (例如轉 csv) 都交給它了: 強大且易用的 zq

臺中市 151 公車停靠站地圖 在 hacker news 上面看到這篇 Introducing zq: an Easier (and Faster) Alternative to jq 。 玩懂了之後, 發現用它來轉檔 (json => csv 或 csv => json) 及篇輯/篩選 json 檔的內容超方便, 甚至比 jq 簡單很多! 只是他們把 zq 的太多功能與特色擠在一篇文章介紹, 所以步調太快, 有點難懂。 這裡我拿臺中市 151 公車停靠站 tcbus-151.json 來作範例, 展示最常用的 zq 基本語法。 這個檔案是從 ptx 公共運輸整合資訊流通服務平台 撈出來的。

一、 簡介

  1. github 的 release 頁面 下載適合你的作業系統的版本。 以我下載的 linux-amd64 版本來說, 解壓縮之後把 zq 與 zed 兩個執行檔搬到 /usr/bin 底下就可以了。
  2. 雖然今天的主角是跑來搶 jq 飯碗的 zq, 不過 jq 拿來排版 json 檔的功能還是無可取代的: jq . 151.json
  3. 試車: zq -i json 'yield this' 151.json 呃.. 預設的輸出格式是擠成一團的 zq 格式; 可加上 -Z 選項, 會產生排版過的 ZSON 格式。 ZSON 幾乎跟 json 一樣, 主要的不同就是 object 的 key 不需要引號。
  4. -i json 告知 zq 輸入檔為 json 格式。 通常 zq 會自己判斷; 但如果輸入檔太大而你讓它自己猜, 它會產生 buffer exceeded max size 的錯誤。
  5. 可以把 yield 想成是 print。 至於 this 就是 「目前正在處理的這一串輸入資料」, 等同於 jq 語法裡的句點 (.) 。 以本例來說, this 就是整個 json 結構。
  6. 可以用 -f json (或合併簡寫為 -j) 來指定改採 json 格式輸出。 不過此時輸出又擠成一團, 而且 zq 無法排版 json 格式。 可以把輸出交給 jq 排版:
    zq -i json -j 'yield this' 151.json | jq . > a.json
  7. 驗證一下: 以結構來說, ZSON 格式的輸出跟 json 格式的輸出基本上是相同的:
    zq -i json -Z 'yield this' 151.json | perl -pe 's/^(\s*)(\w+)/$1"$2"/ ; s/ / /g' | diff - a.json

二、 簡單的通用篩選語法

以下的每個指令, 建議在後面都加上 | jq . | less 比較方便檢視結果, 例如想要數總共有幾列或是與原始的 151.json 對照等等。

  1. 從最外層的陣列當中擷取一個元素、 再擷取那個 object 其中的一組 key-value pair:
    zq -j 'this[1].Stops' 151.json
  2. 對陣列的每個元素做... (類似 jq 的 map、 python 的 list comprehension, 或是一般程式語言的 for 迴圈)
    zq -j 'this[1].Stops | over this | yield StopName.Zh_tw | collect(this)' 151.json
    (Google 翻譯讀者請把 StopName.Zh_tw 改成 StopName.En) 其中 collect(this) 的目的是把那些被 over this 拆開的一個個獨立的 object 串回成一個 json 陣列。 在以下的指令中, 都省略這一句。 反正散開的格式, jq 一樣可以美化排版。
  3. 找到想要擷取的那幾個欄位, 在 yield 指令當中重建成希望的輸出格式:
    zq -j 'this[1].Stops | over this | yield {name:StopName.Zh_tw, lat:StopPosition.PositionLat, lon:StopPosition.PositionLon}' 151.json
    事實上, 上面的 yield 可以省略, 因為每當 zq 看到一對單獨出現的 {} 或 [] 就會自動假設前面省略了 yield 指令。
  4. 其實上例 zq 命令當中的前兩個指令可以合併。 還有, 輸出 object 時, 可以只寫 key 的名稱代表那個節點底下的整個 subtree:
    zq -j 'over this[1].Stops | {StopName, StopPosition}' 151.json
  5. 加上過濾條件, 例如只對 StopSequence<9 的那些站牌有興趣 (臺中市議會到臺中高鐵站):
    zq -j 'over this[1].Stops | StopSequence<9 | {StopName, StopPosition}' 151.json

最後這個句型應該足以應付多數簡單的 json 資料篩選情境:

  1. 一層一層往內挖, 聚焦有興趣的某個子陣列, 用 over 把問題簡化成只需要思考如何處理陣列的一個元素。
  2. 採用類似 sql 的 where 語法或是 python 的帶有 「if 條件式」 的 list comprehension 語法, 用一個邏輯判斷式只保留某些元素。
  3. 用 「被省略掉的 yield」 建構出希望列印的一個元素。

三、 json 轉 csv 再轉 geojson

  1. 從 json 格式的輸入檔撈出某些部分並產生 csv 格式的輸出檔變得很簡單: 只需要如上產生每列一個單層的 object, 並指定改以 csv 作為輸出格式。 我們順便將結果存檔:
    zq -f csv 'over this[1].Stops | {StopName, StopPosition}' 151.json > 151.csv
  2. 用文字編輯器或是試算表軟體打開 151.csv 查看, 並且把第一列 (標題列) 簡化成: zh_TW,en_US,lon,lat,geohash 重點就是把欄位名稱裡的句點 (.) 刪掉。 如果留著它, 後續 zq 再去處理這個 csv 檔時, object 的 key 就必須用引號括起來, 比較囉嗦。 當然也可以在 zq 命令列上 yield 時直接指定; 不過手動編輯更簡單。
  3. 接下來我們把 csv 再轉成 geojson。 因為輸入格式是 csv, 先以列為單位思考, 為每列建立一個 feature, 再用 collect() 把它串成一個 json array, 最後在外面包上一層, 成為 FeatureCollection:
    zq -j '{ type:"Feature", geometry:{ type:"Point", coordinates:[lon,lat]}, properties:{ name:zh_TW,description:en_US } } | collect(this) | { type:"FeatureCollection", features:collect }' 151.csv | jq . > 151.geojson
  4. 最後這個 json 檔可以上傳到 OpenStreetMap 的 umap 服務, 成為 台中市 151 公車停靠站地圖。 其實如果欄位名稱取得恰當, umap 也可以直接讀 csv 檔。 不過這節的重點就是要展示 zq 的肌肉 :-)

四、 一些實用範例

  1. [2023/7/18] 從 台中市環保地圖 可以下載所有資料, 例如存檔成 tc-recycle.geojson 好了。 想要抓出其中含有 「衣」 的資料, 可以這樣做: zq -i json -j 'over features | "衣" | collect(this)' tc-recycle.geojson | jq . > clothes.geojson 然後就可以餵給 leaflet 的 cluster map

五、 zq 語法心得整理

初學 zq 時, 我最先注意到的是它較簡潔的語法: object 的 key (通常) 不必加引號。

其次, this 如果是一個 object, 那麼它的每一個 key 都直接變成一個變數名稱, 可以拿來放在運算式裡 (例如 StopSequence<9)、 列印輸出 (例如 yield StopName.Zh_twyield StopName), 甚至可以被 put 指令把它的值替換掉, 例如:
zq -j 'over this[1].Stops | put StopSequence:=35-StopSequence' 151.json
會產生與輸入幾乎相同的 json 內容, 只是其中的 StopSequence 會倒過來排。

總之每個 key 怎麼看就像是一個變數, 這也讓 zq 的指令變得比 jq 容易很多: 就把它想成你是在寫 「類 python」 程式, 只是用 "|" 取代換列、 語法略有不同而已。 兩個 "|" 之間的每一小句:

  1. over 就是 (list comprehension 的) for 迴圈。
  2. 如果是一個陣列或 object, 那其實只是前面省略了 yield 的輸出指令。
  3. 邏輯運算式可以想成是 list comprehension 的條件式的部分。 例如:
    zq -j 'over this[1].Stops | len(StopName.Zh_tw)<=12 | yield StopName' 151.json
    印出所有「中文站名最多四個字」的中英文站名。 (因為每個中文字佔 3 個 bytes)
  4. 如果是一個單純的數字或字串 (scalar, 純量), 可以想成是邏輯運算式的簡寫: 如果 this 這棵樹底下有任何元素包含這個數字或字串, 才保留這筆資料。 例如:
    zq -j 'over this[1].Stops | "中" | yield StopName' 151.json
    只保留含有「中」這個字串的停靠站, 並印出它們的中英文站名。 zq 甚至支援 regular expressions。 (我的 regex 教學文: zh_TWen_US)
  5. aggregate functions (例如 sum()、 max()、 count()、 ...) 可以用來摘要 array 或 object 形態的子結構, 例如: echo '[[1,2,4],[8,16]]' | zq 'over this | over this | sum(this)' - 注意命令最後的減號, 表示從 stdin 讀資料。

當你要寫較複雜的 zq 指令時, 這些特性讓你的 zq 指令變得比 jq 指令簡單很多。 我已經被成功改變信仰了 :-) 你也有 json 檔需要做複雜的處理, 是本文簡介的功能所做不到的嗎? 歡迎留言貼上你的問題, 請附上指向原始資料的連結, 並詳細描述你希望的輸出, 或許可以作為我的下一篇教學文的範例 :-)

[12/11 用 zq 處理 json 檔第二層陣列的語法]

沒有留言:

張貼留言

因為垃圾留言太多,現在改為審核後才發佈,請耐心等候一兩天。