關於 web service, unity, blogger 等軟體工程筆記

開發 Blog 文字雲工具

Edit icon 沒有留言
文字雲

空閒時間開發的小工具,能夠統計部落格 (Blog) 文章字詞數量,然後根據字詞出現頻率,組合畫在一張圖片上的功能。

這功能記得從 2017 下半年就想嘗試,然後一直拖拖拖,直到最近才開發完成,回顧主要的原因是莫過於恐懼吧,因為需要學習如何將功能部署到 Heroku,然後還要研究回顧 Javascript 與 HTML,來兜出操作介面的 GUI,所以遲遲沒有開始啊⋯⋯。

根據功能需求,用以往的開發經驗,可以輕鬆拆出以下流程:

讀取文章列表 -> 讀取文章內容 -> 分析文章詞組 -> 產生文字雲

為了避免再增加難度,只限定讀取 Blogger 文章 (因為自己的部落格架在該平台上),只需要分析中文與英文的文章即可。

部署服務與操作介面

部落格文字雲服務架構

部落格文字雲服務架構

整個服務架構如上圖,伺服器 (Server) 架設在 Heroku 的免費方案中,負責以下功能:

  • 接收用戶端 (Client) 傳來的查詢要求 (HTTP GET)
  • 從要求的網址讀取該部落格文章內容
  • 計算其文章詞組
  • 輸出回應的詞組頻率資料 (JSON 格式)
  • 注意跨來源的安全性議題,請參考 wiki: CORS

而用戶端為網頁版本,負責以下功能:

  • 提供操作介面
  • 讓使用者 (User) 輸入網址
  • 送出查詢要求給伺服器
  • 讀取伺服器回應的詞組頻率資料
  • 使用文字雲函數庫,繪製結果並呈現在 Canvas (畫布) 上。

原先整個網頁操作介面是想使用 Vue.js 來刻,無奈自己給的時間限制,最後還是用 jQuery 來兜整個網頁介面,並成功放置在自己的部落格網站中。

關於展示放置在此部落格:Blog-word-cloud,建議使用桌機電腦 (desktop),以避免繪製文字雲效能的低落。

讀取文章

分析 Blogger 所提供的 atom feeds,發現其內容已經包含文章列表以及內容,只要讀這份文件,就可以輕鬆完成部落格資源的爬蟲 (crawler) 呢。

預設使用情境是提供 Blogger 網址,從其網址所回應的網頁 HTML 中,找到 atom feeds 的位置:

<link rel="alternate" type="application/atom+xml" title="思元的開發筆記 - Atom" href="https://dev.twsiyuan.com/feeds/posts/default" />

從 atom feeds 內容中,可找到該 feed 下一頁的資料,完成整個部落格文章的爬蟲,需反覆進行直到沒有下一頁資料為止:

<link rel='next' type='application/atom+xml' href='https://www.blogger.com/feeds/6174582485908741059/posts/default?start-index=26&amp;max-results=25'/>

在 Go 實作中,利用網路函式庫 http,送出 HTTP 要求,並下載其回應內容進行分析,利用 goquery 這套類似 jQuery 的函數庫,來操作讀取部落格網頁的內容,藉此讀取 atom feeds 的資源位置:

import (
   "net/http"
   "net/url"
   "github.com/PuerkitoBio/goquery"
)

func AtomURL(u string) (string, error) {
   resp, err := http.Get(u)
   if err != nil {
      return "", err
   }

   doc, err := goquery.NewDocumentFromResponse(resp)
   if err != nil {
      return "", err
   }

   aurl := ""
   doc.Find("link").Each(func(i int, s *goquery.Selection) {
      if rel, exist := s.Attr("rel"); exist && strings.ToLower(rel) == "alternate" {
         if ttype, exist := s.Attr("type"); exist && strings.ToLower(ttype) == "application/atom+xml" {
            aurl, _ = s.Attr("href")
         }
      }
   })

   if len(aurl) > 0 {
      ux, err := url.Parse(aurl)
      if err != nil {
         return "", err
      }
      return ux.String(), nil
   }
   
   return "", errors.New("Cannot find atom link")
}

接著從 atom url 讀取其內容,利用 gofeed 這套函數庫來處理結構化資料 (懶得自己寫啊),所有的文章原始碼都會被放置在 Post.Content

import (
   "encoding/xml"
   "io/ioutil"
   “net/http"
   "github.com/mmcdole/gofeed"
)

type Post struct {
   Title string `json:"Title"`
   Content string `json:"Content"`
   Published *time.Time `json:"PublishedTime"`
   Updated *time.Time `json:"UpdatedTime"`
}

func PostsFromAutomURL(u string) ([]Post, error) {
   resp, err := http.Get(u)
   if err != nil {
      return nil, err
   }

   defer resp.Body.Close()
   b, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      return nil, err
   }

   // Try parse
   fp := gofeed.NewParser()
   feed, err := fp.ParseString(string(b))
   if err != nil {
      return nil, err
   }

   c := make([]atomContent, 0)
   for _, item := range feed.Items {
      c = append(c, atomContent{
         Title: item.Title,
         Content: item.Content,
         Published: item.PublishedParsed,
         Updated: item.UpdatedParsed,
      })
   }

   // For next?
   xmls := struct {
      Links []struct {
         Rel string `xml:"rel,attr"`
         Type string `xml:"type,attr"`
         Href string `xml:"href,attr"`
      } `xml:"link"`
   }{}

   if err := xml.Unmarshal(b, &xmls); err != nil {
      return nil, err
   }

   nextURL := ""
   for _, link := range xmls.Links {
      if strings.ToLower(link.Rel) == "next" {
         nextURL = link.Href
         break
      }
   }

   if len(nextURL) > 0 {
      cnext, err := PostsFromAutomURL(nextURL)
      if err != nil {
         return nil, err
      }
      c = append(c, cnext...)
   }

   return c, nil
}

完成部落格文章爬蟲,取得所有文章的 HTML 程式碼,經過以下 HTML tags 剔除與整理,便可以進行下一步的處理。

import (
   "github.com/PuerkitoBio/goquery"
)

func HtmlFilter(content string) (string, error) {
   // Remove all html tags
   // Remove <blockquote/> && <code/> && <scripts/>
   doc, err := goquery.NewDocumentFromReader(strings.NewReader(content))
   if err != nil {
      return "", err
   }

   doc.Find("blockquote").ReplaceWithHtml(" ")
   doc.Find("script").ReplaceWithHtml(" ")
   doc.Find("code").ReplaceWithHtml(" ")
   doc.Find("p").AppendHtml("\n")
   doc.Find("br").AppendHtml("\n")

   return doc.Text(), nil
}

分析文章詞組

分析文章詞組是一個不容易的學問,完全沒有任何的想法要如何實作其演算法,通常不知道如何開始,就是直接上網尋找,看看有沒有適合開源的函式庫可以使用。

很幸運的是在 timdream 那找到此功能實作,可惜的是 Javascript 的實作版本,由於計畫採用 Go 來開發服務伺服器,當時有幾條路可以選擇:

  • 程式碼直接放在網頁上 (用使用者瀏覽器的運算資源來分析)
  • 改用 Node.js 來呼叫其函數
  • 研究 Go 呼叫 Javascript 函數的方法
  • 改用 Go 重新實作該演算法

後來看看 timdream 所實作的程式碼,發現該演算法不是相當困難,考慮時間因素,直接使用 Go 實作該演算法,會比研究其他方案更能省時,且會執行應該會更有效率。

實作過程中還算順利,其他相依性函數庫也都能找到其他 Go 實作的版本,最終將程式碼上傳至 Github - wordfreq

因此完成伺服器主要處理用戶端要求的 http.Handler 程式碼,分析要求網址,取得 Atom 位置,讀取文章內容並且剔除 HTML tags,最後使用 wordfreq 計算詞組頻率,採用 REST JSON 形式輸出:

import (
   "fmt"
   "net/http"
   "net/url"

   "github.com/twsiyuan/wordfreq"
   "github.com/unrolled/render"
)

func MainHandler() http.Handler {
   return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
      qs := req.URL.Query()
      u := qs.Get("u")
      if len(u) <= 0 {
         fmt.Fprintf(w, "Bad request, not url. query 'u' is empty.")
         w.WriteHeader(http.StatusBadRequest)
         return
      }

      if ux, err := url.Parse(u); err != nil || !ux.IsAbs() {
         fmt.Fprintf(w, "Bad request, invalid url.")
         w.WriteHeader(http.StatusBadRequest)
         return
      }

      aurl, err := AtomURL(u)
      if err != nil {
         w.WriteHeader(http.StatusBadRequest)
         fmt.Fprintf(w, "Error: %v", err)
         return
      }

      c, err := PostsFromAutomURL(aurl)
      if err != nil {
         panic(err)
      }

      wfreq, err := wordfreq.New(wordfreq.Options{})
      if err != nil {
         panic(err)
      }

      for _, cc := range c {
         text, err := HtmlFilter(cc.Content)
         if err != nil {
            panic(err)
         }
         wfreq.Process(text)
      }

      if l := wfreq.List(); len(l) > 1000 {
         renderer.JSON(w, http.StatusOK, l[:1000])
      } else {
         renderer.JSON(w, http.StatusOK, l)
      }
   })
}

產生文字雲

直接採用 timdream 在 Javascript 實作的函數庫,能直接在瀏覽器網頁執行,利用 Canvas (畫布) 來繪製,只要準備好字詞頻率資料,其使用方式也相當簡單:

var canvas = document.getElementById(‘my_canvas’);
var list = from_remote; // format: [[‘foo’, 12], ['bar', 6]]
WordCloud(canvas, { list: list, drawOutOfBound: true });

更多細節可參考其放置在 Github 的程式碼

沒有留言: