LoginSignup
8
9

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-22

この記事はQiita アドベントカレンダー 2022 Python 24日目の記事です。

OSS開発の社内Wikiツール GrowiAPIが公開されてましたので、それを使って遊んでみました。

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

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

完成品イメージ

完成品スクショ

Screenshot from 2022-06-19 23-28-07.png

完成品標準出力

完成品標準出力
# :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

完成品コード2

クリックでコード全体を表示
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)

出力結果(目標)

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

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のデモページ にアカウントを作って試した結果が上のスクショです。

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

要素定義

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

ランキングの一要素を表現する Rank タプルです。
namedtuple を使っているので、ドットプロパティメソッドが使えます。
Growi APIの/page で取得できるJSONオブジェクトの一部を要素としています。

要素名 意味
path str ページパス
id str ページID
liker int ライクした人の数
seen int 足跡の数
commentCount int コメント数
authors int 編集者数
class Ranks(UserList):
    """Growi記事ランキング"""
    def __init__(self, *args):
        """List of Rank"""
        super().__init__(*args)

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

エントリーポイント

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は省略可能です。

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()します。

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

ランキング取得処理

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.listAPIを実行し、
各ページの情報をfor文で操作していきます。

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/listAPIを実行するので、サーバーに負荷をかける重たい処理です。
上記/page.listは一回実行するだけでライク数、足跡数、コメント数を取得できるのに比べて、ただ編集者数を把握するために各ページに対して実行して編集者数だけ4を取得するコスパの悪い処理ですね。

Ranksメソッド

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

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",
]
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()idsafter_idsを比較して、'↗', '➡', '↘' 'new'のリストをarrowsへ格納します。
最後にtitlearrowsafter_ranksを結合して返します。
順位をつけるために最初はenumerate()を使っていましたが、arrowsrankmdと合わせて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,
...
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を記載します。

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):
        """ランク外から2位に上がってきたケース"""
        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:'
        ])
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から呼ばれます。

使い方

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のランキング集計、投稿を行っています。
Dockerfile6

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

  2. github.com/u1and0/growi_tools

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

  4. 他にも変更回数とか見れます。pageというクエリパラメータの意味がわかりません。API仕様には呼び出し方は書いてありましたが、結果の例は書いていませんでした。/revisions/list

  5. Python: オブジェクトのソートについて

  6. やっていはいないけど、docker-compose.ymlにもオーバーライドできそうですね。公式にコミットしてみようかしら。

8
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
9