インデクシングCLI編
SolrでConflの記事を検索 - それでも気分は高専生の個別実装編第一弾です。
ConflではREST APIを通じて、様々な記事をJSON形式で取得することができます。
例えばページを取得したい場合、以下のようにしてページ一覧を取得することができます。
ACCOUNT
とPASSWORD
は自分のConflアカウントです。
ENDPOOINT="conflのホスト名"
LIMIT=30
TYPE=page
EXPAND=space,history,body.view,metadata.labels
read -p "Account: " ACCOUNT
read -p "Password: " -s PASSWORD
read -p "from(int): " FROM
curl -X GET -m ${TIMEOUT} "https://${ENDPOOINT}?type=page&start={$FROM}&limit=${LIMIT}&expand=${EXPAND}" -u "${ACCOUNT}:${PASSWORD}" -H 'Accept: application/json'
したがって、FROM
を0から始め、FROM=FROM+LIMIT
とページネーションを刻んで取得していくことで、いずれは全ページがインデクシングできるというわけです。
しかし、ConflのAPIドキュメントを読んでいただくと分かるのですが、様々な要素がネストしたJSONとなっており、SolrでネストしたJSONをインデクシングするのは大変難しいです。
developer.atlassian.com
そこでConflのJSONをパースするための構造体、Solrに投げるためのJSONを生成する構造体をそれぞれ定義し、インデクシングCLIの中で変換しました。
(以下ソースコードが続きますが、実際に作ったものの中から適当に抜き出したものなので、適宜想像力で補完してください)
ConflのJSONをパースする構造体
type (
Space struct {
Name string `json:"name"`
}
History struct {
CreatedBy struct {
UserName string `json:"username"`
DisplayName string `json:"displayName"`
} `json:"createdBy"`
}
Body struct {
View struct {
Value string `json:"value"`
} `json:"view"`
}
ArticleConfl struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Space Space `json:"space"`
History History `json:"history"`
Body Body `json:"body"`
Links struct {
WebUI string `json:"webui"`
} `json:"_links"`
}
ArticleConflList struct {
Results []ArticleConfl `json:"results"`
Links struct {
Next string `json:"next"`
Prev string `json:"prev"`
} `json:"_links"`
}
)
Solrへ投げるJSONを構成する構造体
type Article struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
SpaceName string `json:"space_name"`
CreatedByUserName string `json:"createdBy_username"`
CreatedByDisplayName string `json:"createdBy_displayName"`
View string `json:"view"`
URL string `json:url`
Labels []string `json:"labels"`
}
func NewArticle(a ArticleConfl, endpoint string) Article {
art := Article{
ID: a.ID,
Type: a.Type,
Title: a.Title,
SpaceName: a.Space.Name,
CreatedByUserName: a.History.CreatedBy.UserName,
CreatedByDisplayName: a.History.CreatedBy.DisplayName,
View: a.Body.View.Value,
URL: endpoint + a.Links.WebUI,
Labels: []string{},
}
for k, terms := range common.LABEL_TERMS {
for _, t := range terms {
if strings.Contains(art.Title, t) {
art.Labels = append(art.Labels, k)
break
}
}
}
return art
}
Conflではラベルという機能があるのですが、このラベルは全社的な約束があまりなく、著者の部署によってバラバラになってしまうので、MTGや議事録など特定の目的の単語が含まれる記事に対して、labelsというフィールドを追加する処理を施しました。
今回決めたラベリングの単語は以下のようになります。
var (
LABEL_TERMS = map[string][]string{
"調査": {"調査", "サーベイ", "レポート", "まとめ", "検証"},
"試験": {"試験", "テスト", "検証"},
"MTG": {"議事録", "会議", "MTG", "ミーティング", "話し合い"},
"運用": {"運用方法", "オペレーション", "使用方法", "進め方", "手順"},
"Tips": {"Tips", "tips", "幸せになれる", "ノウハウ"},
"標準": {"標準", "スタンダード", "規則", "規約", "基準"},
"仕様": {"仕様", "エラーコード", "設計"},
"ホーム": {"ホーム"},
}
)
次にConflへリクエストを組み立てレスポンスを受け取ります。
func FetchArticlesStr(endpoint, expand, username, password string, from, limit int) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", errors.Wrap(err, "Invalid endpoint")
}
u.Path = path.Join(u.Path, CONTENT_API_PATH)
q := u.Query()
q.Set("type", common.CONFL_CONTENT_TYPE)
q.Set("start", strconv.Itoa(from))
q.Set("limit", strconv.Itoa(limit))
q.Set("expand", expand)
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return "", errors.Wrap(err, "Failed to make request")
}
req.Header.Add("Accept", CONTENT_TYPE_JSON)
req.Header.Add("Authorization", toBasicAuthHeader(username, password))
client := new(http.Client)
res, err := client.Do(req)
defer res.Body.Close()
if err != nil {
return "", errors.Wrap(err, "failed to fetch data")
} else if res.StatusCode != http.StatusOK {
return "", errors.New(fmt.Sprintf("HTTP(%d)", res.StatusCode))
}
bytes, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "failed to read response data")
}
return string(bytes), nil
}
ここでベーシック認証は"ユーザ名:パスワード"という形式の文字列をBase64エンコーディングしているため、以下のような形で実装できます。
RFC 7617 - The 'Basic' HTTP Authentication Scheme
func toBasicAuthHeader(username, password string) string {
encoded := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
return fmt.Sprintf("Basic %s", encoded)
}
あとはJSON形式にエンコーディングして、Solrに投げれば終了です。
func fetchArticlesConfl(endpoint, expand, username, password string, from, limit int) (model.ArticleConflList, error) {
artiListStr, err := util.FetchArticlesStr(endpoint, expand, username, password, from, limit)
if err != nil {
return model.ArticleConflList{}, errors.Wrap(err, "Failed to featch Article string")
}
var artiList model.ArticleConflList
if err := json.Unmarshal([]byte(artiListStr), &artiList); err != nil {
return model.ArticleConflList{}, errors.Wrap(err, "Failed to parse JSON string")
}
return artiList, nil
}
func indexingArticle(endpoint, core string, commit bool, articles []model.Article) error {
bytes, err := json.Marshal(articles)
if err != nil {
return errors.Wrap(err, "Failed to convert to json")
}
return util.IndexingSolr(endpoint, core, commit, string(bytes))
}
Solrへのリクエストはこちら
(投稿するのにGETリクエストなんですねー、不思議)
func IndexingSolr(endpoint, core string, commit bool, documentsStr string) error {
u, err := url.Parse(fmt.Sprintf(PATH_TEMPLATE, endpoint, core))
if err != nil {
return errors.Wrap(err, "Invalid URL")
}
q := u.Query()
q.Set("commit", strconv.FormatBool(commit))
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), strings.NewReader(documentsStr))
if err != nil {
return errors.Wrap(err, "Failed to make request")
}
req.Header.Add("Content-type", CONTENT_TYPE_JSON)
req.Header.Add("charset", CHAR_SET)
log.Println(req.URL.String())
log.Println(req.Header)
log.Println(req.Body)
client := new(http.Client)
res, err := client.Do(req)
defer res.Body.Close()
if err != nil {
return errors.Wrap(err, "failed to fetch data")
} else if res.StatusCode != http.StatusOK {
return errors.New(fmt.Sprintf("HTTP(%d)", res.StatusCode))
}
return nil
}
とまあこんな感じでインデクシングCLIを作りました。
何千、何万件も記事があると手動インデクシングなんてできないですからね... (^_^;)
次回、Solrの環境構築編へ続く...