OSS開発wikiツールGrowiのサイドバーに記事ランキングを作った話

この記事は[Qiita アドベントカレンダー 2022 Python](https://qiita.com/advent-calendar/2022/python) 24日目の記事です。


OSS開発の社内Wikiツール [Growi](https://growi.org/ja/)の[API](https://docs.growi.org/en/api/rest-v3.html)が公開されてましたので、それを使って遊んでみました。


Growi記事ランキング投稿
ページの取得、投稿を行う、`Page`, 更新履歴を閲覧する `Revisions` クラスを定義した `growi` モジュールは別記事[^1]にしま ~~した。~~ す。予定

[^1]: GrowiAPIを使って記事を取得・投稿する (まだ書いてない)

この記事はランキング投稿スクリプトの解説記事です。

# 完成品イメージ

## 完成品スクショ

![Screenshot from 2022-06-19 23-28-07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113494/b14b766c-b28b-8f58-58f9-7867834188f6.png)

## 完成品標準出力

<details><summary>完成品標準出力</summary>

```markdown
# :heart:ライクが多いランキングトップ10

1. :arrow_right: [/お試し](https://demo.growi.org/5ee0e945ac9357004883964d) :heart:2 :footprints:17 :speech_balloon:0 :pencil2:2
2. :arrow_right: [/お試し/改善](https://demo.growi.org/5f0d697a7cec480048dba270) :heart:2 :footprints:6 :speech_balloon:1 :pencil2:1
3. :arrow_upper_right: [/お試し1/はじめてのページ777](https://demo.growi.org/62b03e339f17db565044b295) :heart:1 :footprints:1 :speech_balloon:1 :pencil2:1
4. :arrow_upper_right: [/お試し/Ranking](https://demo.growi.org/62acf2940b8a39f163ef243d) :heart:1 :footprints:1 :speech_balloon:0 :pencil2:1
5. :arrow_upper_right: [/お試し/はじめてのページ/入れ子のページ](https://demo.growi.org/6281d16d6fa435d7f5925b6c) :heart:1 :footprints:3 :speech_balloon:0 :pencil2:1
6. :arrow_upper_right: [/お試し/はじめてのページkeeeeeeesuke99/入れ子ページ](https://demo.growi.org/6138b7cc285c8c000c142683) :heart:1 :footprints:3 :speech_balloon:0 :pencil2:1
7. :arrow_upper_right: [/お試し/はじめてのページ8](https://demo.growi.org/60a37ca862e1c30049ddf60b) :heart:1 :footprints:3 :speech_balloon:1 :pencil2:1
8. :arrow_upper_right: [/お試し/はじめてのページ10](https://demo.growi.org/607d9616e73c630049b23776) :heart:1 :footprints:4 :speech_balloon:0 :pencil2:1
9. :arrow_upper_right: [/お試し/はじめてのページぽい/入れ子のページぽい](https://demo.growi.org/607547fa4101e20049da2993) :heart:1 :footprints:2 :speech_balloon:0 :pencil2:1
10. :new: [/お試し/はじめてのページ3](https://demo.growi.org/5fca12dbc5c66700485f1ed4) :heart:1 :footprints:3 :speech_balloon:0 :pencil2:1

# :footprints:足跡が多いランキングトップ10

1. :arrow_right: [/お試し](https://demo.growi.org/5ee0e945ac9357004883964d) :heart:2 :footprints:17 :speech_balloon:0 :pencil2:2
2. :arrow_right: [/お試しです/はじめてのページ](https://demo.growi.org/5db14ee94dc19b0044efe9ea) :heart:0 :footprints:11 :speech_balloon:0 :pencil2:1
3. :arrow_right: [/お試し/改善](https://demo.growi.org/5f0d697a7cec480048dba270) :heart:2 :footprints:6 :speech_balloon:1 :pencil2:1
4. :arrow_right: [/お試しです/はじめてのページ/入れ子のページ](https://demo.growi.org/5db1507c4dc19b0044efe9ef) :heart:0 :footprints:6 :speech_balloon:0 :pencil2:1
5. :arrow_right: [/お試しa/はじめてのページ/おりたたみ](https://demo.growi.org/62148d1287b16dd2e145e757) :heart:0 :footprints:5 :speech_balloon:0 :pencil2:1
6. :arrow_right: [/お試しa/はじめてのページ/入れ子のページ](https://demo.growi.org/62148abc87b16dd2e145e04f) :heart:0 :footprints:5 :speech_balloon:0 :pencil2:1
7. :arrow_right: [/お試し/改善/タグの関係_blockdiag](https://demo.growi.org/5f0e826405904e00485a210a) :heart:0 :footprints:5 :speech_balloon:0 :pencil2:1
8. :arrow_right: [/お試し/改善/入れ子のページ](https://demo.growi.org/5f0d69c87cec480048dba273) :heart:0 :footprints:5 :speech_balloon:0 :pencil2:1
9. :arrow_right: [/お試し/はじめてのページ10](https://demo.growi.org/607d9616e73c630049b23776) :heart:1 :footprints:4 :speech_balloon:0 :pencil2:1
10. :arrow_right: [/お試しです](https://demo.growi.org/5c6b517016763b003f629b9f) :heart:0 :footprints:4 :speech_balloon:0 :pencil2:2

# :speech_balloon:コメントが多いランキングトップ10

1. :arrow_right: [/お試し/改善](https://demo.growi.org/5f0d697a7cec480048dba270) :heart:2 :footprints:6 :speech_balloon:1 :pencil2:1
2. :arrow_right: [/お試し/改善/タグの関係_draw.io](https://demo.growi.org/5f0d6deb7cec480048dba27f) :heart:0 :footprints:4 :speech_balloon:1 :pencil2:1
3. :arrow_right: [/お試し/はじめてのページ8](https://demo.growi.org/60a37ca862e1c30049ddf60b) :heart:1 :footprints:3 :speech_balloon:1 :pencil2:1
4. :arrow_right: [/お試し2/はじめてのページ](https://demo.growi.org/5e2ced5688ba150043d9b4e1) :heart:0 :footprints:3 :speech_balloon:1 :pencil2:1
5. :arrow_right: [/お試し/改善/A1](https://demo.growi.org/5f0d6cea7cec480048dba279) :heart:0 :footprints:2 :speech_balloon:1 :pencil2:1
6. :arrow_right: [/お試しです/初めてのページ/入れ子のページ](https://demo.growi.org/5e0421bf88ba150043d9b35b) :heart:0 :footprints:2 :speech_balloon:1 :pencil2:1
7. :arrow_upper_right: [/お試し1/はじめてのページ777](https://demo.growi.org/62b03e339f17db565044b295) :heart:1 :footprints:1 :speech_balloon:1 :pencil2:1
8. :arrow_upper_right: [/お試し](https://demo.growi.org/5ee0e945ac9357004883964d) :heart:2 :footprints:17 :speech_balloon:0 :pencil2:2
9. :arrow_upper_right: [/お試しです/はじめてのページ](https://demo.growi.org/5db14ee94dc19b0044efe9ea) :heart:0 :footprints:11 :speech_balloon:0 :pencil2:1
10. :new: [/お試しです/はじめてのページ/入れ子のページ](https://demo.growi.org/5db1507c4dc19b0044efe9ef) :heart:0 :footprints:6 :speech_balloon:0 :pencil2:1

# :pencil2:編集者が多いランキングトップ10

1. :arrow_right: [/お試し](https://demo.growi.org/5ee0e945ac9357004883964d) :heart:2 :footprints:17 :speech_balloon:0 :pencil2:2
2. :arrow_right: [/お試しです](https://demo.growi.org/5c6b517016763b003f629b9f) :heart:0 :footprints:4 :speech_balloon:0 :pencil2:2
3. :arrow_right: [/お試し/改善](https://demo.growi.org/5f0d697a7cec480048dba270) :heart:2 :footprints:6 :speech_balloon:1 :pencil2:1
4. :arrow_right: [/お試し/改善/タグの関係_draw.io](https://demo.growi.org/5f0d6deb7cec480048dba27f) :heart:0 :footprints:4 :speech_balloon:1 :pencil2:1
5. :arrow_right: [/お試し/はじめてのページ8](https://demo.growi.org/60a37ca862e1c30049ddf60b) :heart:1 :footprints:3 :speech_balloon:1 :pencil2:1
6. :arrow_right: [/お試し2/はじめてのページ](https://demo.growi.org/5e2ced5688ba150043d9b4e1) :heart:0 :footprints:3 :speech_balloon:1 :pencil2:1
7. :arrow_right: [/お試し/改善/A1](https://demo.growi.org/5f0d6cea7cec480048dba279) :heart:0 :footprints:2 :speech_balloon:1 :pencil2:1
8. :arrow_right: [/お試しです/初めてのページ/入れ子のページ](https://demo.growi.org/5e0421bf88ba150043d9b35b) :heart:0 :footprints:2 :speech_balloon:1 :pencil2:1
9. :arrow_upper_right: [/お試し1/はじめてのページ777](https://demo.growi.org/62b03e339f17db565044b295) :heart:1 :footprints:1 :speech_balloon:1 :pencil2:1
10. :new: [/お試しです/はじめてのページ](https://demo.growi.org/5db14ee94dc19b0044efe9ea) :heart:0 :footprints:11 :speech_balloon:0 :pencil2:1
```

</details>

## 完成品コード[^2]

[^2]: [github.com/u1and0/growi_tools](https://github.com/u1and0/growi_tools)

<details><summary>クリックでコード全体を表示</summary>

```python:ranking.py
#!/usr/bin/env python3
"""Growi記事ランキング投稿
usage:
    $ python ranking.py DST [SRC] [TOP]

    # ランキングを表示するページパスを指定
    $ python ranking.py /Ranking
    # ランキングを表示するページパスとランキング集計元の親ページパスを指定
    $ python ranking.py /Ranking /From/Root
    # ランキングを表示するページパスとランキング集計元の親ページパスとトップ5の集計を指定
    $ python ranking.py /Ranking /From/Root 5
"""
import re
import argparse
from typing import Iterator
from operator import attrgetter
from collections import UserList, namedtuple
from typing import Union
from more_itertools import chunked
from growi import Page, Revisions

fields = {
    "path": "",
    "id": "",
    "liker": 0,
    "seen": 0,
    "commentCount": 0,
    "authors": 0
}
Rank = namedtuple("Rank", fields.keys(), defaults=fields.values())


class Ranks(UserList):
    """Growi記事ランキング"""
    def __init__(self, *args):
        """List of Rank"""
        super().__init__(*args)

    def sort(self, key: str, reverse=True):
        """ランキングのリストに対してkeyでソートをかける"""
        return self.data.sort(key=attrgetter(key), reverse=reverse)

    def convert(self) -> list[str]:
        """ランキングリストをGrowiマークダウン形式のリストに変換する"""
        return [
            f"[{rank.path}]({Page.origin}/{rank.id}) :heart:{rank.liker} \
:footprints:{rank.seen} :speech_balloon:{rank.commentCount} \
:pencil2:{rank.authors}" for rank in self.data
        ]

    def order(self, top: int, ids: list[str]) -> list[str]:
        """top(数字)のリストを上位topの数でランキングづけする。
        過去のランクidsがあれば過去ランクとの比較を行う。
        """
        after_ranks: list[str] = self[:top].convert()
        # arrows初期値、idsがないとき==初めてランキングを作るとき
        arrows = ("" for _ in range(top))
        if ids:
            after_ids: list[str] = [i.id for i in self[:top]]
            arrows: Iterator[str] = Ranks.shift(ids, after_ids)
        body = [
            f"{i}. {arrow} {elem}"
            for i, arrow, elem in zip(range(1, 1 + top), arrows, after_ranks)
        ]
        return body

    @staticmethod
    def shift(before: list, after: list) -> Iterator[str]:
        """ afterのインデックスbeforeに比べて上がってたら上、
        下がってたら下、横ばいだったら横の記号をリストで返す。
        """
        before_ranks: list[Union[int, float]] = \
            (after.index(i) if i in after else float("inf") for i in before)
        for after_rank, before_rank in enumerate(before_ranks):
            sub: int = before_rank - after_rank
            if sub == float("inf"):
                yield ":new:"
            elif sub > 0:
                yield ":arrow_upper_right:"
            elif sub < 0:
                yield ":arrow_lower_right:"
            else:
                yield ":arrow_right:"

    @staticmethod
    def read_ids(paragraph: str) -> list[str]:
        """paragraph からpage idのみをリストで抜き出す"""
        return re.findall(r"[a-f0-9]{24}", paragraph)


def init(path: str = "/") -> Ranks:
    """Generate List of Rank"""
    page = Page(path)
    pages = page.list(prop_access=True, limit=1000).pages
    rank_list = Ranks()
    for page in pages:
        revisions = Revisions(page._id, limit=100)
        rank = Rank(page.path, page._id, len(page.liker), len(page.seenUsers),
                    page.commentCount, len(revisions.authors()))
        rank_list.append(rank)
    return rank_list


def main(dst: str, src: str = "/", top=10, dryrun=False):
    """Growiページパスsrcからランキング情報を収集し、
    Growiページパスdstへランキングを記したマークダウン形式の文字列を投稿する。
    第2引数以降省略でき、デフォルトで"/"からランキングを作成する。
    """
    ranks: Ranks = init(src)
    rank_page = Page(dst)
    if rank_page.exist:
        before_ids = Ranks.read_ids(rank_page.body)
        before_ids_chunk = chunked(before_ids, top)
    ranking_element = (
        (f"# :heart:ライクが多いランキングトップ{top}\n\n", "liker"),
        (f"\n\n# :footprints:足跡が多いランキングトップ{top}\n\n", "seen"),
        (f"\n\n# :speech_balloon:コメントが多いランキングトップ{top}\n\n", "commentCount"),
        (f"\n\n# :pencil2:編集者が多いランキングトップ{top}\n\n", "authors"),
    )
    page_body = ""
    for title, key in ranking_element:
        ranks.sort(key)
        try:
            chunk = next(before_ids_chunk)
        except (StopIteration, NameError):
            chunk = None
        ranking_md = ranks.order(top, chunk)
        page_body += title
        page_body += "\n".join(ranking_md)

    if dryrun:
        # Just print test
        print(page_body)
        return
    # Post page
    res = rank_page.post(page_body)
    print(res)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("dst", help="Growiのランキング表示先ページパス")
    parser.add_argument("src",
                        nargs="?",
                        default="/",
                        help="Growiのランキング集計元ページパス")
    parser.add_argument("top",
                        nargs="?",
                        type=int,
                        default=10,
                        help="ランキング上位数")
    parser.add_argument("-n",
                        "--dryrun",
                        action="store_true",
                        help="標準出力へマークダウンをprintするのみで、記事投稿しない。")
    args = parser.parse_args()
    main(args.dst, args.src, args.top, dryrun=args.dryrun)
```

</details>


# 出力結果(目標)

`print()`したときに下記のような出力を期待するコードを書きます。[^3]

[^3]: Growiのマークダウンは1,2,3,4,...と数字を増やさなくても1,1,1,1,...だけでカウントアップしてくれますが、コンソールへ`print()`したときにわかりやすいという目的で1,2,3,4...とプログラムでカウントアップするようにします。

```
1. ↗[/なんかの/ページ](http://192.168.***.***:3000/8d1235b1293ab3f532cb772b) ❤3 👣5 🗨6 ✏2
2. ➡[/なにかの/サイト](http://192.168.***.***:3000/3cb6a636123ab3f53236ba6e) ❤3 👣5 🗨6 ✏2
3. ↘[/だれかの/ページ](http://192.168.***.***:3000/b473a78d123aba636123ab3f) ❤3 👣5 🗨6 ✏2
4. ↗[/なにげに/ペース](http://192.168.***.***:3000/73a78d123a3ab3f532c3236e) ❤3 👣5 🗨6 ✏2
...
```

# 前準備
環境変数にGrowiのURLとアクセストークンを指定する必要があります。
URLはデフォルトで`http://localhost:3000`が指定されますので、
ローカルで動いているGrowiに対しては指定の必要がありません。

アクセストークンは必須です。

試しに [Growiのデモページ](https://demo.growi.org/5c9403b8ef06c40058a243e8) にアカウントを作って試した結果が上のスクショです。

"/"ルートからランキングを作るとページ数が5800以上と多くて1分程時間がかかりましたが、"お試し"ページが80ページほどでちょうどよかったので、[ "/お試し/Ranking" ](https://demo.growi.org/62acf2940b8a39f163ef243d)にランキングを作成してみました。 10秒ほどで作成できます。


# 要素定義

```python
fields = {
    "path": "",
    "id": "",
    "liker": 0,
    "seen": 0,
    "commentCount": 0,
    "authors": 0
}
Rank = namedtuple("Rank", fields.keys(), defaults=fields.values())
```

ランキングの一要素を表現する `Rank` タプルです。
`namedtuple` を使っているので、ドットプロパティメソッドが使えます。
[Growi APIの`/page`](https://docs.growi.org/redoc.html#operation/getPage) で取得できるJSONオブジェクトの一部を要素としています。

|要素名|  型 | 意味 |
|----|------|-------|
|path|  str | ページパス |
|id|  str | ページID |
|liker|  int | ライクした人の数 |
|seen|  int | 足跡の数 |
|commentCount|  int | コメント数 |
|authors|  int | 編集者数 |


```python
class Ranks(UserList):
    """Growi記事ランキング"""
    def __init__(self, *args):
        """List of Rank"""
        super().__init__(*args)
```

`Rank` の要素を並べ、ランキングのリストを表現する `Ranks` クラスです。
`UserList`を継承していますので、`append()`, `index()` メソッドを使ってリストのように扱えます。


# エントリーポイント

```python
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("dst", help="Growiのランキング表示先ページパス")
    parser.add_argument("src",
                        nargs="?",
                        default="/",
                        help="Growiのランキング集計元ページパス")
    parser.add_argument("top",
                        nargs="?",
                        type=int,
                        default=10,
                        help="ランキング上位数")
    parser.add_argument("-n",
                        "--dryrun",
                        action="store_true",
                        help="標準出力へマークダウンをprintするのみで、記事投稿しない。")
    args = parser.parse_args()
    main(args.dst, args.src, args.top, dryrun=args.dryrun)
```

引数を解析して `main()` 関数へ渡しています。
`$ python ranking.py DST [SRC] [TOP] `のようにして使います。
`ranking.py`へ渡す引数は DST = ランキングを表示するページパス, SRC = ランキング集計元の親ページパス、 TOP = 上位数を意味します。
SRC, TOPは省略可能です。

```python:main()
def main(dst: str, src: str = "/", top=10, dryrun=False):
    """Growiページパスsrcからランキング情報を収集し、
    Growiページパスdstへランキングを記したマークダウン形式の文字列を投稿する。
    第2引数以降省略でき、デフォルトで"/"からランキングを作成する。
    """
    ranks: Ranks = init(src)
    rank_page = Page(dst)
    if rank_page.exist:
        before_ids = Ranks.read_ids(rank_page.body)
        before_ids_chunk = chunked(before_ids, top)
    ranking_element = (
        (f"# :heart:ライクが多いランキングトップ{top}\n\n", "liker"),
        (f"\n\n# :footprints:足跡が多いランキングトップ{top}\n\n", "seen"),
        (f"\n\n# :left_speech_bubble:コメントが多いランキングトップ{top}\n\n",
         "commentCount"),
        (f"\n\n# :pencil2:編集者が多いランキングトップ{top}\n\n", "authors"),
    )
    page_body = ""
    for title, key in ranking_element:
        ranks.sort(key)
        try:
            chunk = next(before_ids_chunk)
        except (StopIteration, NameError):
            chunk = None
        ranking_md = ranks.order(top, chunk)
        page_body += title
        page_body += "\n".join(ranking_md)

    if dryrun:
        # Just print test
        print(page_body)
        return
    # Post page
    res = rank_page.post(page_body)
    print(res)
```

`main()`では`ranking_element`の中身を`for`で回して、タイトルとランキングを組み合わせたマークダウン形式の文字列を作ります。

ページ内容(`rank_page.body`) は`page_body`と同じ形式のマークダウン文字列です。
その内容を逆に解析して`Ranks`と比較するページIDのリストを作成します。

更新前のランキングリストを `more_itertools.chunked()`で一定数のリストに分割します。
ここではトップ10のランキングを作りたいので、
タイトル+40行のマークダウンを4x10のリストオブリストに変換しました。

前のランキングがなければ、`try`分岐で`ranks.order()`へ`None`を渡します。
すでにアップされているランキングページの内容を取得します。
前回ランキングと現在ランキングを比較して、前回と比べて順位が上がった、下がったを判定するために使います。


最後にページの内容(`page_body`)を`DST`で指定したページへ投稿し、結果のJSONを`print()`します。



# その他関数、メソッドの解説

## ランキング取得処理


```python:init()前半
def init(path: str = "/") -> Ranks:
    """Generate List of Rank"""
    page = Page(path)
    pages = page.list(prop_access=True, limit=1000).pages
    rank_list = Ranks()
```

`init()` 関数で各ページのページパス、ID、ライク数、コメント数などを含んだ`Rank`のリスト `Ranks` =ランキングを取得します。
この時点では並び替えられていないので、ランキングとは言いませんね。

ランキングを取得する関数です。
ランキングの取得とは、要するにGrowi APIの結果として得たJSONを解析して、
ページIDやライク数などの要素を`Rank`タプルへ格納します。

ルートページ"/"に対して[`/page.list`](https://docs.growi.org/redoc.html#operation/listPages)APIを実行し、
各ページの情報を`for`文で操作していきます。

```python:init()後半
    for page in pages:
        revisions = Revisions(page._id, limit=100)
        rank = Rank(page.path, page._id, len(page.liker), len(page.seenUsers),
                    page.commentCount, len(revisions.authors()))
        rank_list.append(rank)
    return rank_list
```

各ページIDを使って更新履歴(`Revisions`)を取得します。
ここの処理がページ数分だけ[`/revisions/list`](https://docs.growi.org/redoc.html#tag/Revisions/paths/~1revisions~1list/get)APIを実行するので、サーバーに負荷をかける重たい処理です。
上記[`/page.list`](https://docs.growi.org/redoc.html#operation/listPages)は一回実行するだけでライク数、足跡数、コメント数を取得できるのに比べて、ただ編集者数を把握するために各ページに対して実行して編集者数だけ[^4]を取得するコスパの悪い処理ですね。

[^4]: 他にも変更回数とか見れます。pageというクエリパラメータの意味がわかりません。API仕様には呼び出し方は書いてありましたが、結果の例は書いていませんでした。[`/revisions/list`](https://docs.growi.org/redoc.html#tag/Revisions/paths/~1revisions~1list/get)

## `Ranks`メソッド

```python:Ranks.sort()
    def sort(self, key: str, reverse=True):
        """ランキングのリストに対してkeyでソートをかける"""
        return self.data.sort(key=attrgetter(key), reverse=reverse)
```

ランキングリストをソートします。
リストクラスの`sort()`メソッドの上書きです。
keyで並べ替えターゲットを指定して、デフォルトで降順(数値の高いものが最初)になるように並べ替えます。
keyは属性を指定するために`operator.attrgetter()`を使います[^5]。

[^5]:[Python: オブジェクトのソートについて](https://blog.amedama.jp/entry/2015/12/14/013805)

```python:Ranks.convert()
    def convert(self) -> list[str]:
        """ランキングリストをGrowiマークダウン形式のリストに変換する"""
        return [
            f"[{rank.path}]({Page.origin}/{rank.id}) :heart:{rank.liker} \
:footprints:{rank.seen} :speech_balloon:{rank.commentCount} \
:pencil2:{rank.authors}" for rank in self.data
        ]
```

ランキングリスト`Ranks`をマークダウン形式の文字列を要素としたリストに書き換えます。
リストにしたのはこの後の`order()`で更に加工するためです。

下記のようなリストが返ってきます。

```
[
  "[/なんかの/ページ](http://192.168.***.***:3000/8d1235b1293ab3f532cb772b) ❤3 👣5 🗨6 ✏2",
  "[/なにかの/サイト](http://192.168.***.***:3000/3cb6a636123ab3f53236ba6e) ❤3 👣5 🗨6 ✏2",
  "[/だれかの/ページ](http://192.168.***.***:3000/b473a78d123aba636123ab3f) ❤3 👣5 🗨6 ✏2",
  "[/なにげに/ペース](http://192.168.***.***:3000/73a78d123a3ab3f532c3236e) ❤3 👣5 🗨6 ✏2",
]
```

```python:Ranks.order()
    def order(self, top: int, ids: list[str]) -> list[str]:
        """top(数字)のリストを上位topの数でランキングづけする。
        過去のランクidsがあれば過去ランクとの比較を行う。
        """
        after_ranks: list[str] = self[:top].convert()
        # arrows初期値、idsがないとき==初めてランキングを作るとき
        arrows = ("" for _ in range(top))
        if ids:
            after_ids: list[str] = [i.id for i in self[:top]]
            arrows: Iterator[str] = Ranks.shift(ids, after_ids)
        body = [
            f"{i}. {arrow} {elem}"
            for i, arrow, elem in zip(count(1), arrows, after_ranks)
        ]
        return body
```


`Ranks.sort()`で指定の`key`順にソートし、`Ranks.convert()`で上記のリストを`after_ids`へ格納します。
`after_ids`はランキングのIDのみを取得しています。
`Ranks.shift()`で`ids`と`after_ids`を比較して、'↗', '➡', '↘' 'new'のリストを`arrows`へ格納します。
最後に`title`と`arrows`と`after_ranks`を結合して返します。
順位をつけるために最初は`enumerate()`を使っていましたが、`arrows`と`rankmd`と合わせて3要素を`for`へ渡すので、
`zip()`と`count()`を併せて使います。

こんな結果のリストが帰ってきます。

```
[1. ↗[/なんかの/ページ](http://192.168.***.***:3000/8d1235b1293ab3f532cb772b) ❤3 👣5 🗨6 ✏2,
2. ➡[/なにかの/サイト](http://192.168.***.***:3000/3cb6a636123ab3f53236ba6e) ❤3 👣5 🗨6 ✏2,
3. ↘[/だれかの/ページ](http://192.168.***.***:3000/b473a78d123aba636123ab3f) ❤3 👣5 🗨6 ✏2,
4. ↗[/なにげに/ペース](http://192.168.***.***:3000/73a78d123a3ab3f532c3236e) ❤3 👣5 🗨6 ✏2,
...
```



```python:Ranks.shift()
    @staticmethod
    def shift(before: list, after: list) -> Iterator[str]:
        """ afterのインデックスbeforeに比べて上がってたら上、
        下がってたら下、横ばいだったら横の記号をリストで返す。
        """
        before_ranks: list[Union[int, float]] = \
            (after.index(i) if i in after else float("inf") for i in before)
        for after_rank, before_rank in enumerate(before_ranks):
            sub: int = before_rank - after_rank
            if sub == float("inf"):
                yield ":new:"
            elif sub > 0:
                yield ":arrow_upper_right:"
            elif sub < 0:
                yield ":arrow_lower_right:"
            else:
                yield ":arrow_right:"
```

リスト`before`に対して、リスト`after`の要素のインデックスが上がっていたら上矢印、下がっていたら下矢印、ランクに変動なければ横矢印、ランク外から上がってきた要素には`new`の絵文字を返すイテレータです。
unittestを記載します。

```python:test_ranking.py
import unittest

class TestRanks_shift(unittest.TestCase):
    """Ranks.shift() test"""
    def test_rank_updown(self):
        """ランク内で順位が変わるケース"""
        be, af = "a b c".split(), "c b a".split()
        self.assertEqual(
            list(ranking.Ranks.shift(be, af)),
            [':arrow_upper_right:', ':arrow_right:', ':arrow_lower_right:'])

    def test_rank_new(self):
        """ランク外から上がってきたケース"""
        be, af = "a b c d".split(), "c b a e".split()
        self.assertEqual(list(ranking.Ranks.shift(be, af)), [
            ':arrow_upper_right:', ':arrow_right:', ':arrow_lower_right:',
            ':new:'
        ])

    def test_rank_new2(self):
        """ランク外から２位に上がってきたケース"""
        be, af = "a b c d".split(), "c e a d".split()
        self.assertEqual(list(ranking.Ranks.shift(be, af)), [
            ':arrow_upper_right:', ':new:', ':arrow_lower_right:',
            ':arrow_right:'
        ])
```

```python:Ranks.read_ids()
    @staticmethod
    def read_ids(paragraph: str) -> list[str]:
        """paragraph からpage idのみをリストで抜き出す"""
        return re.findall(r"[a-f0-9]{24}", paragraph)
```

マークダウンからページID(16進数24桁のハッシュ)のみを取得し、リストで返します。
mainから呼ばれます。


# 使い方

```shell-session
usage: ranking.py [-h] [-n] dst [src] [top]

Growi記事ランキング投稿
usage: $ python ranking.py DST [SRC] [TOP]

# ランキングを表示するページパスを指定
$ python ranking.py /Ranking
# ランキングを表示するページパスとランキング集計元の親ページパスを指定
$ python ranking.py /Ranking /From/Root
# ランキングを表示するページパスとランキング集計元の親ページパスとトップ5の集計を指定
$ python ranking.py /Ranking /From/Root 5

positional arguments:
  dst           Growiのランキング表示先ページパス
  src           Growiのランキング集計元ページパス
  top           ランキング上位数

optional arguments:
  -h, --help    show this help message and exit
  -n, --dryrun  標準出力へマークダウンをprintするのみで、記事投稿しない。
```

DSTを"/Sidebar"にして常に左側のサイドバーにランキングが表示されるようにしました。(スクショなし)
SRCはデフォルトの"/"、TOPはデフォルトの"10"ですので、指定していません。
Dockerコンテナ上で`cron`を使って、毎日定期的にGrowiのランキング集計、投稿を行っています。
[Dockerfile](https://github.com/u1and0/growi_tools/blob/master/Dockerfile)[^6]

[^6]: やっていはいないけど、`docker-compose.yml`にもオーバーライドできそうですね。[公式](https://github.com/weseek/growi)にコミットしてみようかしら。