1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiitaを280本書いたら自分でも探せなくなったので、AI要約と「読み応え」で索引を作ってみた

1
Posted at

はじめに

Qiitaの記事が増えてくると、タイトルを見ても「これ、何を書いた記事だったっけ?」となることがあります。私の場合、気づけば280本ほどになっていて、過去の記事を探すのが少し大変になってきました。

また、自分で記事を読み返したり誰かに紹介したりするときには、「何が書いてあるか」だけでなく、「どれくらい分厚い記事なのか」も最初に分かると便利だと思います。

そこで今回は、各記事に次の2つを追加して一覧化してみました。

  • AI Abstract:本文をAIに読ませて、「何をした記事なのか」を一文でまとめる
  • 読み応え:コードや数式などを除いた文章量から、おおよそのボリュームを見えるようにする

つまり、記事を開く前に

これは読みたい記事か?
今これを読む時間はありそうか?

の両方を判断しやすくするのが狙いです。

実際に作ったページはこちらです。

Qiita Article Index

実際の画面

AI Abstractと「読み応え」を一覧で確認できるようにしています。

image.png

操作デモ

キーワード検索や「読み応え」による絞り込みは、こんな感じで操作できます。

demo.gif

作り方

作業は4段階です。

  1. Qiita APIから記事情報を取得してCSVを作る
  2. CSVをChatGPTに渡して ai_abstract 列を追加する
  3. 生成したCSVからHTMLを作る
  4. 必要に応じてHTMLを公開する

1. Qiita APIからCSVを作る

まずQiita APIから、タイトル、タグ、URL、投稿日、本文を取得してCSVに保存します。

CSVは次のような列を持つ形にしています。

title
tags
url
created_at
body
Qiita APIからCSVを作るコード
import requests
import pandas as pd

# =====================
# 設定
# =====================

TARGET_NAME = "sakaimaging"  # 自分のQiitaユーザー名に変更
TOKEN = "XXXXXXXXXXXXXXXX"  # Qiitaで発行したアクセストークンを設定

def fetch_public_posts(user_name, token):

    posts = []
    page = 1

    headers = {
        "Authorization": f"Bearer {token}"
    }

    while True:

        url = "https://qiita.com/api/v2/items"

        params = {
            "page": page,
            "per_page": 100,
            "query": f"user:{user_name}"
        }

        print(f"取得中: page {page}")

        r = requests.get(
            url,
            headers=headers,
            params=params,
            timeout=30
        )

        r.raise_for_status()
        data = r.json()

        if not data:
            break

        # 公開記事だけ
        public_posts = [
            post for post in data
            if not post.get("private", False)
        ]

        posts.extend(public_posts)

        if len(data) < 100:
            break

        page += 1

    return posts


# =====================
# 記事取得
# =====================

posts = fetch_public_posts(
    TARGET_NAME,
    TOKEN
)


# =====================
# DataFrame作成
# =====================

rows = []

for post in posts:

    tags = [
        tag["name"]
        for tag in post.get("tags", [])
    ]

    rows.append({
        "title": post["title"],
        "tags": ", ".join(tags),
        "url": post["url"],
        "created_at": post["created_at"],
        "body": post["body"]
    })


df = pd.DataFrame(rows)


# 日付を整形
df["created_at"] = pd.to_datetime(
    df["created_at"]
).dt.tz_localize(None)


# 古い記事 → 新しい記事の順
df = df.sort_values(
    "created_at",
    ascending=True
).reset_index(drop=True)


# =====================
# CSV保存
# =====================

filename = f"qiita_public_articles_{TARGET_NAME}.csv"

df.to_csv(
    filename,
    index=False,
    encoding="utf-8-sig"
)


# =====================
# 確認
# =====================

print()
print(f"{filename} を保存しました")
print(f"公開記事数: {len(df)}")
print()

print(
    df[
        [
            "title",
            "tags",
            "created_at"
        ]
    ].head(10)
)

TOKEN にはQiitaで発行したアクセストークンを設定します。GitHubなどにコードを公開する場合は、トークンを直接書かず、環境変数などで管理してください。

アクセストークンの発行方法は、こちらの記事を参考にしてください。

2. ChatGPTでAI Abstractを追加する

今回はChatGPT Plus環境の GPT-5.6 Sol / High を使い、先ほど生成したCSVをそのまま添付して ai_abstract 列を追加しました。

各記事の body を実際に読ませて、タイトルの言い換えではなく、「何を対象に、何を行った記事なのか」が一文で分かる要約を作ってもらいます。

AI Abstract生成に使ったプロンプト
添付したCSVには、私が投稿した記事の情報が入っています。

各行について `body` を実際に読み、第三者が一覧を見ただけで「この記事では何をしたのか」が分かる、一文の要約を作成してください。

目的は、記事数が増えてタイトルやタグだけでは内容を把握しにくくなったため、記事本文の内容を一文で表した索引を作ることです。

要約は次の方針で作成してください。

- タイトルを単に言い換えないでください。
- `body` の内容を最も重視してください。`title` と `tags` は補助情報として利用してください。
- 「何を対象に」「何を行い」「何が分かる記事なのか」が可能な範囲で一文から分かるようにしてください。
- 実装記事なら、何を実装したのかを明記してください。
- 比較記事なら、何と何を比較したのかを明記してください。
- 検証記事なら、何をどのように検証したのかを明記してください。
- 解説記事なら、何を理解・利用できるようになる記事なのかを明記してください。
- 本文に書かれていない目的、結果、評価を推測して追加しないでください。
- 専門用語、ソフトウェア名、手法名など、記事を探す上で重要な語は可能な限り残してください。
- 一覧性を優先し、1記事につき日本語1文、80〜140文字程度を目安にしてください。
- 文体と情報量は全記事でできるだけ統一してください。

元のCSVの行数と順序は変更せず、新しく `ai_abstract` 列を追加してください。

`title`、`tags`、`url`、`created_at`、`body` の内容は変更しないでください。

すべての記事を処理したあと、入力記事数と `ai_abstract` を生成した記事数が一致していることも確認してください。

これで、

title
tags
url
created_at
body
ai_abstract

というCSVができます。

3. CSVからHTMLを作る

最後に、ai_abstract を追加したCSVを入力してHTMLを生成します。

基本的には入力CSVを指定するだけです。

python generate_html.py qiita_public_articles_sakaimaging_with_ai_abstract.csv

このスクリプト側で、検索、並び替え、AI Abstractの表示、「読み応え」の計算や絞り込みなどをまとめて行っています。

「読み応え」の文字数については、単純な body の文字数ではなく、コードブロック、数式、URL、Markdown記号などを除いた文章部分を数えるようにしています。

HTMLを生成するコード
generate_html.py
import argparse
import json
import re
from html import escape
from pathlib import Path

import pandas as pd


# ============================================================
# Settings
# ============================================================

# 配布ファイルが更新済みか確認できるようにする識別子。
SCRIPT_VERSION = "2026-09-12-columns10-reset-on-reload"

# カード上に表示する「読み応え」ラベル
# フィルタ自体は連続量なので、この分類には依存しない
READING_LEVELS = [
    (2000, 1, "軽め"),
    (5000, 2, "標準"),
    (12000, 3, "多め"),
]

READING_LEVEL_MAX = 4
READING_LEVEL_MAX_LABEL = "たっぷり"


# ============================================================
# Text utilities
# ============================================================

def split_tags(value):
    """CSV内のカンマ区切りタグをリスト化する。"""

    if pd.isna(value):
        return []

    return [
        tag.strip()
        for tag in str(value).split(",")
        if tag.strip()
    ]


def remove_fenced_blocks(text):
    """
    Markdownのfenced blockを除去する。

    対応:
        ```python
        ...
        ```

        ````bash
        ...
        ````

        ~~~
        ...
        ~~~

    言語指定の有無や種類は問わない。
    """

    if not text:
        return ""

    lines = str(text).splitlines()

    output = []

    in_fence = False
    fence_char = None
    fence_length = 0

    for line in lines:

        stripped = line.lstrip()

        if not in_fence:

            match = re.match(
                r"^(`{3,}|~{3,})",
                stripped,
            )

            if match:

                fence = match.group(1)

                fence_char = fence[0]
                fence_length = len(fence)

                in_fence = True

                continue

            output.append(line)

            continue

        closing_pattern = (
            rf"^{re.escape(fence_char)}"
            rf"{{{fence_length},}}\s*$"
        )

        if re.match(
            closing_pattern,
            stripped,
        ):
            in_fence = False
            fence_char = None
            fence_length = 0

    return "\n".join(output)


def remove_math(text):
    """
    読み応え算出時に、まとまった数式表現を除く。

    厳密なMarkdown/TeXパーサではなく、
    記事同士を比較するためのヒューリスティック。
    """

    # $$ ... $$
    text = re.sub(
        r"\$\$.*?\$\$",
        "",
        text,
        flags=re.DOTALL,
    )

    # \[ ... \]
    text = re.sub(
        r"\\\[.*?\\\]",
        "",
        text,
        flags=re.DOTALL,
    )

    # \( ... \)
    text = re.sub(
        r"\\\(.*?\\\)",
        "",
        text,
        flags=re.DOTALL,
    )

    # equation / align / gather / multline
    environments = (
        "equation",
        "align",
        "gather",
        "multline",
        "eqnarray",
    )

    for env in environments:

        text = re.sub(
            rf"\\begin\{{{env}\*?\}}"
            rf".*?"
            rf"\\end\{{{env}\*?\}}",
            "",
            text,
            flags=re.DOTALL,
        )

    # $ ... $ のinline math
    # 完全ではないが「読み物量」用途には十分
    text = re.sub(
        r"(?<!\\)\$(?!\$).*?(?<!\\)\$",
        "",
        text,
        flags=re.DOTALL,
    )

    return text


def readable_text_char_count(body):
    """
    「読み応え」算出用文字数。

    除外:
    - fenced code / math block
    - display / inline math
    - inline code
    - Markdown画像
    - URL
    - HTMLタグ
    - Markdown記号
    - 空白

    目的は厳密な文字数ではなく、
    記事群の中での相対的な「読み物量」の比較。
    """

    if pd.isna(body):
        return 0

    text = str(body)

    # コード・math fenced block
    text = remove_fenced_blocks(text)

    # 数式
    text = remove_math(text)

    # inline code
    text = re.sub(
        r"`{1,2}[^`\n]+`{1,2}",
        "",
        text,
    )

    # Markdown画像
    text = re.sub(
        r"!\[[^\]]*\]\([^)]+\)",
        "",
        text,
    )

    # Markdownリンク
    # [表示文字](URL) → 表示文字
    text = re.sub(
        r"\[([^\]]+)\]\([^)]+\)",
        r"\1",
        text,
    )

    # 生URL
    text = re.sub(
        r"https?://\S+",
        "",
        text,
    )

    # HTMLタグ
    text = re.sub(
        r"<[^>]+>",
        "",
        text,
    )

    # Markdown装飾
    text = re.sub(
        r"[#>*_~`|]",
        "",
        text,
    )

    # 空白・改行
    text = re.sub(
        r"\s+",
        "",
        text,
    )

    return len(text)


def reading_level(chars):
    """カード表示用の4段階ラベル。"""

    for upper, level, label in READING_LEVELS:

        if chars < upper:

            return {
                "level": level,
                "label": label,
            }

    return {
        "level": READING_LEVEL_MAX,
        "label": READING_LEVEL_MAX_LABEL,
    }


# ============================================================
# Main
# ============================================================

def main():

    parser = argparse.ArgumentParser(
        description=(
            "AI Abstract・読み応え分布付き"
            "Qiita記事索引HTMLを生成します。"
        )
    )

    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {SCRIPT_VERSION}",
    )

    parser.add_argument(
        "csv",
        help="入力CSVファイル",
    )

    parser.add_argument(
        "-o",
        "--output",
        help="出力HTMLファイル",
    )

    parser.add_argument(
        "--title",
        default="Qiita Article Index",
        help="ページタイトル",
    )

    parser.add_argument(
        "--author",
        default="",
        help="Qiitaユーザー名",
    )

    args = parser.parse_args()

    csv_path = Path(args.csv)

    if not csv_path.exists():

        raise FileNotFoundError(
            f"CSVファイルが見つかりません: {csv_path}"
        )

    # ========================================================
    # CSV
    # ========================================================

    df = pd.read_csv(
        csv_path
    )

    required = {
        "title",
        "url",
        "created_at",
        "ai_abstract",
    }

    missing = (
        required
        - set(df.columns)
    )

    if missing:

        raise ValueError(
            "必要な列がありません: "
            + ", ".join(
                sorted(missing)
            )
        )

    if "tags" in df.columns:

        tag_col = "tags"

    elif "tag" in df.columns:

        tag_col = "tag"

    else:

        tag_col = None

    df["created_at"] = pd.to_datetime(
        df["created_at"],
        errors="coerce",
    )

    # ========================================================
    # Article data
    # ========================================================

    articles = []

    for _, row in df.iterrows():

        # ----------------------------------------------------
        # Date
        # ----------------------------------------------------

        created_at = row["created_at"]

        if pd.isna(created_at):

            date = ""
            timestamp = None

        else:

            date = created_at.strftime(
                "%Y.%m.%d"
            )

            timestamp = int(
                created_at.timestamp()
            )

        # ----------------------------------------------------
        # Abstract
        # ----------------------------------------------------

        abstract = row["ai_abstract"]

        if pd.isna(abstract):
            abstract = ""

        # ----------------------------------------------------
        # Tags
        # ----------------------------------------------------

        tags = (
            split_tags(
                row[tag_col]
            )
            if tag_col
            else []
        )

        # ----------------------------------------------------
        # 読み応え文字数
        # ----------------------------------------------------

        if "body" in df.columns:

            chars = readable_text_char_count(
                row["body"]
            )

        else:

            chars = 0

        level_info = reading_level(
            chars
        )

        articles.append(
            {
                "title": str(
                    row["title"]
                ),
                "url": str(
                    row["url"]
                ),
                "date": date,
                "timestamp": timestamp,
                "tags": tags,
                "abstract": str(
                    abstract
                ),
                "reading_chars": chars,
                "reading_level": level_info["level"],
                "reading_label": level_info["label"],
            }
        )

    # ========================================================
    # percentile
    #
    # カード上のbarometer表示用
    # ========================================================

    sorted_chars = sorted(
        article["reading_chars"]
        for article in articles
    )

    n_articles = len(
        sorted_chars
    )

    for article in articles:

        value = article[
            "reading_chars"
        ]

        below = sum(
            x <= value
            for x in sorted_chars
        )

        if n_articles > 1:

            percentile = (
                (below - 1)
                / (n_articles - 1)
                * 100
            )

        else:

            percentile = 50

        article[
            "reading_percentile"
        ] = round(
            percentile,
            1,
        )

    # ========================================================
    # JSON
    # ========================================================

    articles_json = json.dumps(
        articles,
        ensure_ascii=False,
    ).replace(
        "</",
        "<\\/",
    )

    # ========================================================
    # Output
    # ========================================================

    output_path = (
        Path(args.output)
        if args.output
        else csv_path.with_suffix(
            ".html"
        )
    )

    page_title = escape(
        args.title
    )

    author = escape(
        args.author
    )

    author_html = (
        f'<span class="author">@{author}</span>'
        if author
        else ""
    )

    # ========================================================
    # HTML
    # ========================================================

    html = r"""<!DOCTYPE html>

<html lang="ja">

<head>

<meta charset="UTF-8">

<meta
    name="viewport"
    content="width=device-width, initial-scale=1.0"
>

<title>__PAGE_TITLE__</title>


<style>

/* ==========================================================
   Variables
========================================================== */

:root {

    --columns: 5;

    --bg: #f7f9f8;
    --surface: #ffffff;

    --text: #252826;
    --subtext: #717873;

    --border: #e1e6e3;

    --soft: #f1f4f2;
    --soft-hover: #e8edea;

    --accent: #55c500;
    --accent-dark: #438f20;
    --accent-soft: #eef9e9;

}


/* ==========================================================
   Base
========================================================== */

* {
    box-sizing: border-box;
}

html {
    scroll-behavior: smooth;
}

body {

    margin: 0;

    background:
        var(--bg);

    color:
        var(--text);

    font-family:
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        "Noto Sans JP",
        "Helvetica Neue",
        Arial,
        sans-serif;

    line-height:
        1.7;

}

button,
input,
select {
    font: inherit;
}


/* ==========================================================
   Layout
========================================================== */

.container {

    width:
        min(
            3200px,
            100%
        );

    margin:
        0 auto;

    padding:
        40px 26px 90px;

}


/* ==========================================================
   Header
========================================================== */

.header {

    margin-bottom:
        27px;

}

.header-top {

    display:
        flex;

    align-items:
        center;

    flex-wrap:
        wrap;

    gap:
        12px;

}

h1 {

    margin:
        0;

    font-size:
        clamp(
            28px,
            4vw,
            38px
        );

    line-height:
        1.2;

    letter-spacing:
        -0.035em;

}

.author {

    padding:
        4px 10px;

    border-radius:
        999px;

    background:
        var(--accent-soft);

    color:
        var(--accent-dark);

    font-size:
        12px;

    font-weight:
        650;

}

.description {

    margin:
        10px 0 0;

    color:
        var(--subtext);

    font-size:
        15px;

}

.header-info {

    display:
        flex;

    gap:
        8px;

    align-items:
        center;

    margin-top:
        10px;

    color:
        #959b97;

    font-size:
        12px;

}


/* ==========================================================
   Toolbar
========================================================== */

.toolbar-wrapper {

    position:
        sticky;

    top:
        0;

    z-index:
        100;

    margin:
        0 -10px 18px;

    padding:
        10px;

    background:
        rgba(
            247,
            249,
            248,
            .94
        );

    backdrop-filter:
        blur(12px);

    -webkit-backdrop-filter:
        blur(12px);

}

.toolbar {

    display:
        grid;

    grid-template-columns:
        minmax(270px, 1fr)
        auto
        auto
        auto
        auto;

    gap:
        8px;

    align-items:
        stretch;

}


/* ==========================================================
   Search
========================================================== */

.search-wrap {

    position:
        relative;

    min-width:
        0;

}

.search-icon {

    position:
        absolute;

    left:
        16px;

    top:
        50%;

    transform:
        translateY(-50%);

    color:
        #989f9a;

    pointer-events:
        none;

}

.search {

    width:
        100%;

    height:
        50px;

    padding:
        0 16px 0 42px;

    border:
        1px solid
        var(--border);

    border-radius:
        14px;

    outline:
        none;

    background:
        var(--surface);

    color:
        var(--text);

    font-size:
        15px;

}

.search:focus {

    border-color:
        #9dcb89;

    box-shadow:
        0 0 0 4px
        rgba(
            85,
            197,
            0,
            .09
        );

}


/* ==========================================================
   Controls
========================================================== */

.control {

    min-height:
        44px;

    padding:
        0 13px;

    border:
        1px solid
        var(--border);

    border-radius:
        11px;

    outline:
        none;

    background:
        var(--surface);

    color:
        var(--text);

    font-size:
        13px;

}

.toggle-control {

    display:
        flex;

    align-items:
        center;

    justify-content:
        space-between;

    gap:
        9px;

    min-height:
        50px;

    padding:
        0 12px;

    border:
        1px solid
        var(--border);

    border-radius:
        12px;

    background:
        var(--surface);

    white-space:
        nowrap;

    font-size:
        12px;

}


/* ==========================================================
   Toggle
========================================================== */

.switch {

    position:
        relative;

    display:
        inline-block;

    width:
        38px;

    height:
        22px;

    flex-shrink:
        0;

}

.switch input {

    opacity:
        0;

    width:
        0;

    height:
        0;

}

.slider {

    position:
        absolute;

    inset:
        0;

    cursor:
        pointer;

    border-radius:
        999px;

    background:
        #d8ddda;

    transition:
        .2s;

}

.slider::before {

    content:
        "";

    position:
        absolute;

    width:
        16px;

    height:
        16px;

    top:
        3px;

    left:
        3px;

    border-radius:
        50%;

    background:
        #ffffff;

    box-shadow:
        0 1px 4px
        rgba(
            0,
            0,
            0,
            .15
        );

    transition:
        .2s;

}

.switch input:checked
+ .slider {

    background:
        var(--accent);

}

.switch input:checked
+ .slider::before {

    transform:
        translateX(16px);

}


/* ==========================================================
   Reading Explorer
========================================================== */

.reading-explorer {

    margin:
        4px 0 22px;

    padding:
        18px 20px 16px;

    border:
        1px solid
        var(--border);

    border-radius:
        16px;

    background:
        var(--surface);

}

.reading-header {

    display:
        flex;

    justify-content:
        space-between;

    align-items:
        flex-end;

    gap:
        16px;

    margin-bottom:
        14px;

}

.reading-title {

    font-size:
        13px;

    font-weight:
        700;

}

.reading-description {

    margin-top:
        2px;

    color:
        var(--subtext);

    font-size:
        11px;

}

.reading-range-label {

    color:
        var(--accent-dark);

    font-size:
        13px;

    font-weight:
        700;

    white-space:
        nowrap;

}


/* ==========================================================
   Histogram
========================================================== */

.histogram {

    display:
        flex;

    align-items:
        flex-end;

    gap:
        3px;

    height:
        76px;

    margin:
        0 4px;

}

.histogram-bar {

    flex:
        1 1 0;

    min-width:
        2px;

    border-radius:
        3px 3px 1px 1px;

    background:
        #dfe4e1;

    transition:
        background .15s,
        opacity .15s;

}

.histogram-bar.selected {

    background:
        var(--accent);

}

.histogram-bar.dim {

    opacity:
        .35;

}


/* ==========================================================
   Dual range
========================================================== */

.range-area {

    position:
        relative;

    height:
        42px;

    margin-top:
        4px;

}

.range-track {

    position:
        absolute;

    left:
        6px;

    right:
        6px;

    top:
        16px;

    height:
        7px;

    border-radius:
        999px;

    background:
        #e3e7e4;

}

.range-selected {

    position:
        absolute;

    top:
        0;

    bottom:
        0;

    border-radius:
        999px;

    background:
        var(--accent);

}

.range-input {

    position:
        absolute;

    left:
        0;

    top:
        7px;

    width:
        100%;

    height:
        24px;

    margin:
        0;

    appearance:
        none;

    -webkit-appearance:
        none;

    pointer-events:
        none;

    background:
        transparent;

}

.range-input::-webkit-slider-runnable-track {

    height:
        7px;

    background:
        transparent;

}

.range-input::-webkit-slider-thumb {

    appearance:
        none;

    -webkit-appearance:
        none;

    width:
        22px;

    height:
        22px;

    margin-top:
        -7px;

    border:
        3px solid #fff;

    border-radius:
        50%;

    background:
        var(--accent);

    box-shadow:
        0 1px 5px
        rgba(
            0,
            0,
            0,
            .22
        );

    pointer-events:
        auto;

    cursor:
        grab;

}

.range-input::-webkit-slider-thumb:active {

    cursor:
        grabbing;

}

.range-input::-moz-range-track {

    height:
        7px;

    background:
        transparent;

}

.range-input::-moz-range-thumb {

    width:
        16px;

    height:
        16px;

    border:
        3px solid #fff;

    border-radius:
        50%;

    background:
        var(--accent);

    box-shadow:
        0 1px 5px
        rgba(
            0,
            0,
            0,
            .22
        );

    pointer-events:
        auto;

}


/* ==========================================================
   Histogram axis
========================================================== */

.histogram-axis {

    display:
        flex;

    justify-content:
        space-between;

    gap:
        4px;

    margin:
        -3px 3px 0;

    color:
        #9a9f9c;

    font-size:
        10px;

}

.histogram-footer {

    display:
        flex;

    justify-content:
        space-between;

    align-items:
        center;

    gap:
        12px;

    margin-top:
        10px;

}

.histogram-count {

    color:
        var(--subtext);

    font-size:
        11px;

}

.histogram-reset {

    border:
        0;

    background:
        transparent;

    color:
        var(--accent-dark);

    cursor:
        pointer;

    font-size:
        11px;

}


/* ==========================================================
   Reset
========================================================== */

.reset {

    cursor:
        pointer;

    color:
        var(--subtext);

}

.reset:hover {

    background:
        var(--soft);

}


/* ==========================================================
   Result
========================================================== */

.result-bar {

    display:
        flex;

    justify-content:
        space-between;

    align-items:
        center;

    gap:
        18px;

    margin:
        0 2px 17px;

    color:
        var(--subtext);

    font-size:
        13px;

}

.count {

    color:
        #555c58;

    font-weight:
        650;

}

.search-hint {

    color:
        #999f9b;

    font-size:
        12px;

}


/* ==========================================================
   Grid
========================================================== */

.articles {

    display:
        grid;

    grid-template-columns:
        repeat(
            var(--columns),
            minmax(
                0,
                1fr
            )
        );

    gap:
        15px;

    align-items:
        stretch;

}


/* ==========================================================
   Card
========================================================== */

.article-card {

    position:
        relative;

    display:
        flex;

    flex-direction:
        column;

    min-width:
        0;

    padding:
        21px;

    overflow:
        hidden;

    border:
        1px solid
        var(--border);

    border-radius:
        17px;

    background:
        var(--surface);

    box-shadow:
        0 1px 3px
        rgba(
            0,
            0,
            0,
            .018
        );

    transition:
        transform .18s,
        border-color .18s,
        box-shadow .18s;

}

.article-card::before {

    content:
        "";

    position:
        absolute;

    top:
        0;

    left:
        18px;

    right:
        18px;

    height:
        3px;

    border-radius:
        0 0 4px 4px;

    background:
        transparent;

}

.article-card:hover {

    transform:
        translateY(-2px);

    border-color:
        #cad2cd;

    box-shadow:
        0 8px 25px
        rgba(
            0,
            0,
            0,
            .055
        );

}

.article-card:hover::before {

    background:
        var(--accent);

}


/* ==========================================================
   Title
========================================================== */

.article-title {

    margin:
        0 0 13px;

    min-width:
        0;

    font-size:
        17px;

    line-height:
        1.53;

    letter-spacing:
        -0.01em;

    overflow-wrap:
        anywhere;

}

.article-title a {

    color:
        var(--text);

    text-decoration:
        none;

}

.article-title a:hover {

    color:
        var(--accent-dark);

}

.external {

    color:
        #9da39f;

    font-size:
        11px;

    white-space:
        nowrap;

}


/* ==========================================================
   Abstract
========================================================== */

.abstract {

    flex-grow:
        1;

    min-width:
        0;

    margin:
        0 0 20px;

    color:
        #515854;

    font-size:
        14px;

    line-height:
        1.78;

}

.ai-label {

    display:
        inline-block;

    margin-right:
        7px;

    padding:
        1px 6px;

    border-radius:
        5px;

    background:
        var(--accent-soft);

    color:
        var(--accent-dark);

    font-size:
        9px;

    font-weight:
        750;

    letter-spacing:
        .06em;

}


/* ==========================================================
   Meta
========================================================== */

.meta {

    width:
        100%;

    margin-top:
        auto;

}

.tags {

    display:
        flex;

    flex-wrap:
        wrap;

    gap:
        6px;

}

.tag {

    max-width:
        100%;

    padding:
        4px 9px;

    border:
        0;

    border-radius:
        999px;

    background:
        var(--soft);

    color:
        #626964;

    cursor:
        pointer;

    font-size:
        11px;

}

.tag:hover {

    background:
        var(--accent-soft);

    color:
        var(--accent-dark);

}


/* ==========================================================
   Card reading barometer
========================================================== */

.reading-weight {

    margin-top:
        13px;

}

.reading-weight-top {

    display:
        flex;

    justify-content:
        space-between;

    align-items:
        center;

    gap:
        10px;

    margin-bottom:
        5px;

    color:
        #8d948f;

    font-size:
        11px;

}

.reading-weight-name {

    color:
        var(--accent-dark);

    font-weight:
        650;

}

.reading-mini-track {

    position:
        relative;

    height:
        5px;

    border-radius:
        999px;

    background:
        #e4e8e5;

}

.reading-mini-fill {

    position:
        absolute;

    left:
        0;

    top:
        0;

    bottom:
        0;

    border-radius:
        999px;

    background:
        var(--accent);

}

.reading-mini-dot {

    position:
        absolute;

    top:
        50%;

    width:
        9px;

    height:
        9px;

    transform:
        translate(
            -50%,
            -50%
        );

    border:
        2px solid #fff;

    border-radius:
        50%;

    background:
        var(--accent);

    box-shadow:
        0 1px 3px
        rgba(
            0,
            0,
            0,
            .2
        );

}

.reading-char-label {

    margin-top:
        4px;

    color:
        #a0a6a2;

    font-size:
        10px;

}


/* ==========================================================
   Date
========================================================== */

.date-row {

    display:
        flex;

    justify-content:
        flex-end;

    width:
        100%;

    margin-top:
        10px;

    padding-top:
        9px;

    border-top:
        1px solid
        #f0f2f1;

}

time {

    color:
        #999f9b;

    font-size:
        11px;

    white-space:
        nowrap;

}


/* ==========================================================
   Visibility
========================================================== */

body.hide-abstract
.abstract {

    display:
        none;

}

body.hide-meta
.meta {

    display:
        none;

}


/* ==========================================================
   Column sizes
========================================================== */

html[data-columns="1"]
.article-card {

    padding:
        27px 30px;

}

html[data-columns="1"]
.article-title {

    font-size:
        20px;

}

html[data-columns="1"]
.abstract {

    font-size:
        15px;

}


html[data-columns="4"] .article-card,
html[data-columns="5"] .article-card,
html[data-columns="6"] .article-card {

    padding:
        17px;

}

html[data-columns="4"] .article-title,
html[data-columns="5"] .article-title,
html[data-columns="6"] .article-title {

    font-size:
        15.5px;

}

html[data-columns="4"] .abstract,
html[data-columns="5"] .abstract,
html[data-columns="6"] .abstract {

    font-size:
        13px;

}

html[data-columns="7"] .article-card,
html[data-columns="8"] .article-card {

    padding:
        15px;

}

html[data-columns="7"] .article-title,
html[data-columns="8"] .article-title {

    font-size:
        14.5px;

}

html[data-columns="7"] .abstract,
html[data-columns="8"] .abstract {

    font-size:
        12.5px;

}

html[data-columns="9"] .article-card,
html[data-columns="10"] .article-card {

    padding:
        13px;

}

html[data-columns="9"] .article-title,
html[data-columns="10"] .article-title {

    font-size:
        14px;

}

html[data-columns="9"] .abstract,
html[data-columns="10"] .abstract {

    font-size:
        12px;

}


/* ==========================================================
   Highlight
========================================================== */

mark {

    padding:
        0 .08em;

    border-radius:
        3px;

    background:
        #e8f7df;

    color:
        inherit;

}


/* ==========================================================
   Empty
========================================================== */

.empty {

    display:
        none;

    padding:
        80px 20px;

    text-align:
        center;

    color:
        var(--subtext);

}

.empty.show {

    display:
        block;

}


/* ==========================================================
   Back to top
========================================================== */

.back-top {

    position:
        fixed;

    right:
        22px;

    bottom:
        22px;

    z-index:
        90;

    width:
        42px;

    height:
        42px;

    border:
        1px solid
        var(--border);

    border-radius:
        50%;

    background:
        rgba(
            255,
            255,
            255,
            .95
        );

    color:
        #68706b;

    cursor:
        pointer;

    opacity:
        0;

    pointer-events:
        none;

    box-shadow:
        0 4px 18px
        rgba(
            0,
            0,
            0,
            .08
        );

}

.back-top.show {

    opacity:
        1;

    pointer-events:
        auto;

}


/* ==========================================================
   Medium
========================================================== */

@media (
    max-width: 1100px
) {

    .toolbar {

        grid-template-columns:
            1fr
            auto
            auto;

    }

    .search-wrap {

        grid-column:
            1 / -1;

    }

}


/* ==========================================================
   Narrow
========================================================== */

@media (
    max-width: 760px
) {

    .view-control {

        display:
            none;

    }

    .container {

        padding:
            25px 14px 60px;

    }

    .toolbar-wrapper {

        margin:
            0 -6px 15px;

        padding:
            7px 6px;

    }

    .toolbar {

        grid-template-columns:
            1fr
            1fr;

    }

    .search-wrap {

        grid-column:
            1 / -1;

    }

    .reset {

        grid-column:
            1 / -1;

    }

    .reading-header {

        display:
            block;

    }

    .reading-range-label {

        margin-top:
            7px;

    }

    .histogram {

        height:
            58px;

    }

    .result-bar {

        display:
            block;

    }

    .search-hint {

        display:
            none;

    }

}


/* ==========================================================
   Refined Qiita-style interface
========================================================== */

:root {
    --bg: #f6f8f7;
    --surface: #ffffff;
    --text: #242827;
    --subtext: #66706b;
    --muted: #8c9691;
    --border: #dfe5e1;
    --border-strong: #cad4ce;
    --soft: #f2f5f3;
    --soft-hover: #e9eeeb;
    --accent: #55c500;
    --accent-dark: #347a16;
    --accent-soft: #edf8e8;
    --focus: rgba(85, 197, 0, .18);
}

body {
    background: var(--bg);
    font-feature-settings: "palt" 1;
}

.container {
    width: min(3200px, 100%);
    padding: clamp(24px, 4vw, 48px) clamp(14px, 3vw, 32px) 88px;
}

.header {
    margin-bottom: 22px;
}

.header-top {
    gap: 10px;
}

h1 {
    font-size: clamp(25px, 3vw, 34px);
    letter-spacing: -.025em;
}

.description {
    margin-top: 8px;
    font-size: 14px;
}

.header-info {
    margin-top: 7px;
    color: var(--muted);
}

.toolbar-wrapper {
    margin: 0 -8px 16px;
    padding: 8px;
    background: rgba(246, 248, 247, .93);
}

.toolbar {
    display: grid;
    grid-template-columns: minmax(320px, 1fr) auto auto;
    gap: 8px;
    align-items: stretch;
}

.display-controls,
.utility-controls {
    display: flex;
    min-width: 0;
    gap: 8px;
}

.search {
    height: 52px;
    padding-left: 45px;
    border-color: var(--border-strong);
    border-radius: 10px;
    box-shadow: 0 1px 2px rgba(25, 35, 29, .025);
    font-size: 15px;
}

.search-icon {
    left: 17px;
    color: #75817a;
    font-size: 22px;
    line-height: 1;
}

.search:focus-visible,
.control:focus-visible,
.tag:focus-visible,
.histogram-reset:focus-visible,
.reset:focus-visible,
.back-top:focus-visible {
    border-color: var(--accent);
    outline: 3px solid var(--focus);
    outline-offset: 1px;
}

.toggle-control,
.control {
    min-height: 52px;
    border-radius: 9px;
}

.toggle-control {
    padding: 0 12px;
    color: #46504b;
    font-weight: 600;
}

.control {
    padding: 0 12px;
}

.reset {
    border-color: #d7ddd9;
    color: #5f6964;
    white-space: nowrap;
}

.reset:hover,
.reset:active {
    background: #eef2ef;
    color: #343b37;
}

.reading-explorer {
    margin: 0 0 17px;
    padding: 20px 22px 16px;
    border-color: #d8e2dc;
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(24, 40, 30, .025);
}

.reading-summary {
    display: flex;
    width: 100%;
    min-width: 0;
    align-items: center;
    gap: 14px;
    padding: 0;
    border: 0;
    background: transparent;
    color: inherit;
    cursor: pointer;
    text-align: left;
}

.reading-summary:focus-visible {
    outline: 3px solid var(--focus);
    outline-offset: 5px;
    border-radius: 4px;
}

.reading-summary-main {
    display: flex;
    min-width: 0;
    align-items: baseline;
    gap: 10px;
}

.reading-summary-title {
    display: flex;
    align-items: center;
    gap: 8px;
    color: var(--text);
    font-size: 15px;
    font-weight: 700;
    white-space: nowrap;
}

.reading-summary-title::before {
    content: "";
    width: 4px;
    height: 16px;
    border-radius: 2px;
    background: var(--accent);
}

.reading-summary-main strong {
    color: var(--accent-dark);
    font-size: 12px;
    white-space: nowrap;
}

.compact-histogram {
    display: flex;
    width: min(190px, 30vw);
    height: 24px;
    align-items: flex-end;
    gap: 2px;
    margin-left: auto;
}

.compact-histogram span {
    flex: 1 1 0;
    min-width: 1px;
    border-radius: 1px 1px 0 0;
    background: var(--accent);
    opacity: .72;
}

.compact-histogram span.dim {
    background: #d7ded9;
    opacity: .55;
}

.compact-count {
    color: var(--subtext);
    font-size: 11px;
    white-space: nowrap;
}

.reading-chevron {
    width: 20px;
    color: #78827d;
    font-size: 15px;
    text-align: center;
    transition: transform .18s ease;
}

.reading-body {
    padding-top: 13px;
}

.reading-explorer:not(.collapsed) .reading-summary-main strong,
.reading-explorer:not(.collapsed) .compact-histogram,
.reading-explorer:not(.collapsed) .compact-count {
    display: none;
}

.reading-explorer:not(.collapsed) .reading-chevron {
    margin-left: auto;
}

.reading-explorer.collapsed {
    padding-top: 13px;
    padding-bottom: 13px;
}

.reading-explorer.collapsed .reading-body {
    display: none;
}

.reading-explorer.collapsed .reading-chevron {
    transform: rotate(180deg);
}

.reading-body .reading-title {
    display: none;
}

.reading-body .reading-description {
    margin-left: 0;
}

.reading-header {
    align-items: center;
    margin-bottom: 11px;
}

.reading-title {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 15px;
}

.reading-title::before {
    content: "";
    width: 4px;
    height: 16px;
    border-radius: 2px;
    background: var(--accent);
}

.reading-description {
    margin: 3px 0 0 12px;
    font-size: 11px;
}

.reading-range-label {
    display: flex;
    align-items: baseline;
    gap: 8px;
    padding: 6px 10px;
    border: 1px solid #dce8d7;
    border-radius: 8px;
    background: #f7fcf4;
    color: var(--accent-dark);
}

.range-summary-label {
    color: #768276;
    font-size: 10px;
    font-weight: 600;
}

.reading-range-label strong {
    font-size: 13px;
}

.distribution-plot {
    padding: 8px 10px 4px;
    border: 1px solid #e5eae7;
    border-radius: 9px;
    background: linear-gradient(#fbfcfb, #fff);
}

.histogram {
    height: 82px;
    margin: 0 3px;
    gap: 2px;
    border-bottom: 1px solid #dfe5e1;
}

.histogram-bar {
    min-width: 1px;
    border-radius: 2px 2px 0 0;
    background: #d9dfdb;
}

.histogram-bar.selected {
    background: var(--accent);
    opacity: .82;
}

.histogram-bar.dim {
    background: #dfe4e1;
    opacity: .55;
}

.range-area {
    height: 34px;
    margin: -1px 1px 0;
}

.range-track {
    left: 5px;
    right: 5px;
    top: 11px;
    height: 5px;
    background: #dfe4e1;
}

.range-selected {
    background: var(--accent);
}

.range-input {
    top: 1px;
    height: 25px;
}

#rangeMin { z-index: 3; }
#rangeMax { z-index: 4; }
#rangeMin:focus { z-index: 5; }
#rangeMax:focus { z-index: 5; }

.range-input::-webkit-slider-thumb {
    width: 20px;
    height: 20px;
    margin-top: -7px;
    border: 3px solid #fff;
    box-shadow: 0 0 0 1px #49a923, 0 2px 5px rgba(25, 45, 30, .2);
}

.range-input::-moz-range-thumb {
    width: 14px;
    height: 14px;
    box-shadow: 0 0 0 1px #49a923, 0 2px 5px rgba(25, 45, 30, .2);
}

.histogram-axis {
    position: relative;
    display: block;
    height: 18px;
    margin: -2px 1px 0;
    color: #8d9792;
    font-size: 9px;
}

.histogram-axis span {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    white-space: nowrap;
}

.histogram-axis span:first-child,
.histogram-axis span.axis-start {
    transform: none;
}

.histogram-axis span.axis-end {
    transform: translateX(-100%);
}

.range-values {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    margin-top: 10px;
}

.range-value {
    display: flex;
    align-items: baseline;
    gap: 7px;
    min-width: 128px;
    padding: 5px 9px;
    border: 1px solid #e3e8e5;
    border-radius: 7px;
    background: #fafbfa;
}

.range-value span {
    color: var(--muted);
    font-size: 10px;
}

.range-value strong {
    margin-left: auto;
    color: #3c4741;
    font-size: 12px;
}

.range-separator {
    color: #a1aaa5;
    font-size: 12px;
}

.histogram-footer {
    margin-top: 8px;
}

.histogram-count {
    color: #6f7974;
}

.histogram-reset {
    padding: 4px 6px;
    border-radius: 5px;
    font-weight: 600;
}

.histogram-reset:hover,
.histogram-reset:active {
    background: var(--accent-soft);
}

.result-bar {
    margin: 0 2px 13px;
}

.active-filters {
    display: none;
    flex-wrap: wrap;
    gap: 6px;
    margin: -3px 2px 13px;
}

.active-filters.show {
    display: flex;
}

.filter-chip {
    display: inline-flex;
    max-width: 100%;
    align-items: center;
    gap: 6px;
    min-height: 27px;
    padding: 3px 8px;
    overflow: hidden;
    border: 1px solid #d9e4d4;
    border-radius: 6px;
    background: #f6fbf3;
    color: #46623c;
    cursor: pointer;
    font-size: 11px;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.filter-chip:hover,
.filter-chip:active,
.filter-chip:focus-visible {
    border-color: #b9d9aa;
    background: var(--accent-soft);
}

.filter-chip .chip-close {
    color: #7a8c72;
    font-size: 13px;
}

.sticky-status {
    position: absolute;
    top: calc(100% - 3px);
    right: 8px;
    left: 8px;
    display: none;
    min-width: 0;
    align-items: center;
    gap: 8px;
    padding: 6px 8px;
    border: 1px solid #dfe5e1;
    border-radius: 0 0 9px 9px;
    background: rgba(255, 255, 255, .97);
    box-shadow: 0 2px 5px rgba(25, 35, 29, .06);
}

.sticky-status.show {
    display: flex;
}

.sticky-status-filters {
    display: flex;
    min-width: 0;
    flex: 1;
    flex-wrap: wrap;
    gap: 5px;
}

.sticky-status .filter-chip {
    min-height: 24px;
    padding: 2px 7px;
}

.sticky-status-count {
    margin-left: auto;
    color: #59645e;
    font-size: 11px;
    font-weight: 650;
    white-space: nowrap;
}

.count {
    color: #3f4944;
}

.articles {
    gap: 13px;
}

.article-card {
    padding: 20px;
    border-radius: 11px;
    box-shadow: 0 1px 2px rgba(23, 35, 27, .02);
    transition: border-color .15s, box-shadow .15s;
}

.article-card::before {
    display: none;
}

.article-card:hover,
.article-card:focus-within {
    transform: none;
    border-color: #bfcac4;
    box-shadow: 0 3px 12px rgba(27, 42, 32, .055);
}

.article-title {
    margin-bottom: 11px;
    font-size: 17px;
    line-height: 1.48;
}

.article-title a:hover,
.article-title a:focus-visible {
    color: var(--accent-dark);
    text-decoration: underline;
    text-decoration-color: #9ed782;
    text-underline-offset: 3px;
}

.abstract {
    margin-bottom: 17px;
    color: #515b56;
    font-size: 13.5px;
    line-height: 1.73;
}

.ai-label {
    margin-right: 6px;
    padding: 1px 5px;
    border: 1px solid #d7ead0;
    background: #f3faef;
    font-size: 8px;
}

.tags {
    gap: 5px;
}

.tag {
    padding: 3px 8px;
    border: 1px solid #e4e9e6;
    border-radius: 5px;
    background: #f7f9f8;
    color: #606b65;
}

.tag:hover,
.tag:active {
    border-color: #cae2bf;
    background: var(--accent-soft);
    color: var(--accent-dark);
}

.reading-weight {
    margin-top: 12px;
    padding: 9px 10px 8px;
    border: 1px solid #e7ebe8;
    border-radius: 7px;
    background: #fafbfa;
}

.reading-weight-top {
    margin-bottom: 7px;
    color: #77817c;
}

.reading-weight-name {
    padding: 1px 6px;
    border-radius: 4px;
    background: var(--accent-soft);
}

.reading-mini-track {
    height: 4px;
    background: #dfe4e1;
}

.reading-mini-fill {
    opacity: .8;
}

.reading-mini-dot {
    width: 8px;
    height: 8px;
    box-shadow: 0 0 0 1px #49a923;
}

.reading-char-label {
    margin-top: 5px;
    color: #939c97;
    text-align: right;
}

.date-row {
    margin-top: 9px;
    padding-top: 8px;
}

html[data-columns="1"] .article-card {
    padding: clamp(21px, 3vw, 28px);
}

html[data-columns="1"] .article-title {
    font-size: clamp(18px, 2.2vw, 21px);
}

html[data-columns="4"] .article-card,
html[data-columns="5"] .article-card,
html[data-columns="6"] .article-card {
    padding: 15px;
}

html[data-columns="4"] .article-title,
html[data-columns="5"] .article-title,
html[data-columns="6"] .article-title {
    font-size: 15px;
}

html[data-columns="4"] .abstract,
html[data-columns="5"] .abstract,
html[data-columns="6"] .abstract {
    font-size: 12.5px;
    line-height: 1.65;
}

html[data-columns="7"] .article-card,
html[data-columns="8"] .article-card {
    padding: 13px;
}

html[data-columns="7"] .article-title,
html[data-columns="8"] .article-title {
    font-size: 14px;
}

html[data-columns="7"] .abstract,
html[data-columns="8"] .abstract {
    font-size: 12px;
    line-height: 1.62;
}

html[data-columns="9"] .article-card,
html[data-columns="10"] .article-card {
    padding: 11px;
}

html[data-columns="9"] .article-title,
html[data-columns="10"] .article-title {
    font-size: 13.5px;
}

html[data-columns="9"] .abstract,
html[data-columns="10"] .abstract {
    font-size: 11.5px;
    line-height: 1.58;
}

@media (max-width: 1180px) {
    .toolbar {
        grid-template-columns: minmax(280px, 1fr) auto;
    }

    .utility-controls {
        grid-column: 1 / -1;
        justify-content: flex-end;
    }
}

@media (max-width: 760px) {
    .container {
        padding: 22px 12px 58px;
    }

    .toolbar {
        grid-template-columns: 1fr;
    }

    .search-wrap,
    .display-controls,
    .utility-controls {
        grid-column: 1;
    }

    .display-controls {
        display: grid;
        grid-template-columns: 1fr 1fr;
    }

    .utility-controls {
        display: grid;
        grid-template-columns: 1fr 1fr;
    }

    .utility-controls .view-control {
        display: none;
    }

    .sticky-status {
        align-items: flex-start;
        flex-wrap: wrap;
        gap: 5px 8px;
    }

    .sticky-status-filters {
        flex-basis: calc(100% - 78px);
    }

    .utility-controls .reset {
        grid-column: auto;
    }

    .control,
    .toggle-control {
        width: 100%;
        min-height: 45px;
    }

    .search {
        height: 50px;
    }

    .reading-explorer {
        padding: 16px 13px 13px;
    }

    .reading-header {
        display: flex;
        align-items: flex-start;
    }

    .reading-summary {
        gap: 8px;
    }

    .compact-histogram {
        width: min(115px, 26vw);
        gap: 1px;
    }

    .reading-range-label {
        margin-top: 0;
        flex-direction: column;
        gap: 0;
        align-items: flex-end;
    }

    .histogram {
        height: 62px;
        gap: 1px;
    }

    .histogram-axis span:nth-child(2),
    .histogram-axis span:nth-child(4) {
        display: none;
    }

    .range-values {
        gap: 6px;
    }

    .range-value {
        min-width: 0;
        flex: 1;
    }

    .article-card {
        padding: 18px;
    }
}

@media (max-width: 430px) {
    .description {
        font-size: 13px;
    }

    .reading-description {
        max-width: 180px;
    }

    .range-summary-label {
        display: none;
    }

    .reading-range-label {
        padding: 5px 7px;
    }

    .reading-explorer.collapsed .compact-histogram {
        display: none;
    }

    .reading-summary-main {
        gap: 7px;
    }

    .range-separator {
        display: none;
    }

    .histogram-axis span:not(:first-child):not(:last-child) {
        display: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after { transition: none !important; }
}

</style>

</head>


<body>

<div class="container">


<!-- ======================================================
     Header
====================================================== -->

<header class="header">

<div class="header-top">

<h1>
__PAGE_TITLE__
</h1>

__AUTHOR_HTML__

</div>

<p class="description">

AI Abstractと「読み応え」で記事を探索できます。

</p>

<div class="header-info">

<span>
__ARTICLE_COUNT__ articles
</span>

<span>·</span>

<span>
AI-powered index
</span>

</div>

</header>


<!-- ======================================================
     Toolbar
====================================================== -->

<div class="toolbar-wrapper">

<div class="toolbar">


<div class="search-wrap">

<span class="search-icon">
⌕
</span>

<input
    id="search"
    class="search"
    type="search"
    placeholder="タイトル・AI要約・タグを検索"
    autocomplete="off"
    aria-label="記事を検索"
>

</div>


<div class="display-controls" aria-label="表示設定">


<div class="toggle-control">

<span>
AI Abstract
</span>

<label class="switch">

<input
    id="abstractToggle"
    type="checkbox"
    checked
    aria-label="AI Abstractを表示"
>

<span class="slider"></span>

</label>

</div>


<div class="toggle-control">

<span>
Meta
</span>

<label class="switch">

<input
    id="metaToggle"
    type="checkbox"
    checked
    aria-label="メタ情報を表示"
>

<span class="slider"></span>

</label>

</div>


</div>


<div class="utility-controls" aria-label="並び替えと表示列">


<div class="view-control">

<select
    id="columns"
    class="control"
    aria-label="最大列数"
>

<option value="1">最大1列</option>
<option value="2">最大2列</option>
<option value="3">最大3列</option>
<option value="4">最大4列</option>
<option value="5">最大5列</option>
<option value="6">最大6列</option>
<option value="7">最大7列</option>
<option value="8">最大8列</option>
<option value="9">最大9列</option>
<option value="10">最大10列</option>

</select>

</div>


<select
    id="sort"
    class="control"
    aria-label="記事の並び順"
>

<option value="new">
新しい順
</option>

<option value="old">
古い順
</option>

<option value="reading-asc">
読み応え:軽い順
</option>

<option value="reading-desc">
読み応え:重い順
</option>

</select>


<button
    id="reset"
    class="control reset"
    type="button"
>

条件をクリア

</button>


</div>


</div>


<div
    id="stickyStatus"
    class="sticky-status"
    aria-label="スクロール中の現在の検索条件"
    aria-hidden="true"
>

<div
    id="stickyStatusFilters"
    class="sticky-status-filters"
></div>

<span
    id="stickyStatusCount"
    class="sticky-status-count"
></span>

</div>

</div>


<!-- ======================================================
     Reading explorer
====================================================== -->

<section
    id="readingExplorer"
    class="reading-explorer"
>

<button
    id="readingToggle"
    class="reading-summary"
    type="button"
    aria-expanded="true"
    aria-controls="readingBody"
>

<span class="reading-summary-main">
<span class="reading-summary-title">読み応え</span>
<strong id="compactRange">すべて</strong>
</span>

<span
    id="compactHistogram"
    class="compact-histogram"
    aria-hidden="true"
></span>

<span id="compactCount" class="compact-count"></span>

<span class="reading-chevron" aria-hidden="true">⌃</span>

</button>


<div id="readingBody" class="reading-body">

<div class="reading-header">

<div>

<div class="reading-title">
読み応え
</div>

<div class="reading-description">
コード・数式等を除いた説明文量の分布
</div>

</div>

<div
    id="readingRangeLabel"
    class="reading-range-label"
>
<span class="range-summary-label">選択範囲</span>
<strong id="readingRangeText">すべて</strong>
</div>

</div>


<div class="distribution-plot">

<div
    id="histogram"
    class="histogram"
    aria-label="全記事の読み応え分布"
></div>


<div class="range-area">

<div class="range-track">

<div
    id="rangeSelected"
    class="range-selected"
></div>

</div>

<input
    id="rangeMin"
    class="range-input"
    type="range"
    min="0"
    max="1000"
    value="0"
    aria-label="読み応えの下限"
>

<input
    id="rangeMax"
    class="range-input"
    type="range"
    min="0"
    max="1000"
    value="1000"
    aria-label="読み応えの上限"
>

</div>


<div
    id="histogramAxis"
    class="histogram-axis"
></div>

</div>


<div class="range-values" aria-live="polite">

<div class="range-value">
<span>下限</span>
<strong id="rangeMinValue">—</strong>
</div>

<span class="range-separator" aria-hidden="true">〜</span>

<div class="range-value">
<span>上限</span>
<strong id="rangeMaxValue">—</strong>
</div>

</div>


<div class="histogram-footer">

<div
    id="histogramCount"
    class="histogram-count"
></div>

<button
    id="readingReset"
    class="histogram-reset"
    type="button"
>
全範囲に戻す
</button>

</div>

</div>

</section>


<!-- ======================================================
     Results
====================================================== -->

<div class="result-bar">

<div
    id="count"
    class="count"
></div>

<div class="search-hint">
スペース区切りでAND検索
</div>

</div>


<div
    id="activeFilters"
    class="active-filters"
    aria-label="現在の検索条件"
></div>


<!-- ======================================================
     Articles
====================================================== -->

<main
    id="articles"
    class="articles"
></main>


<div
    id="empty"
    class="empty"
>

<strong>
該当する記事がありません
</strong>

<br>

検索条件を変更してみてください。

</div>


</div>


<button
    id="backTop"
    class="back-top"
    type="button"
>
↑
</button>


<script>

/* =========================================================
   Data
========================================================= */

const ARTICLES =
    __ARTICLES_JSON__;


/* =========================================================
   DOM
========================================================= */

const search =
    document.getElementById(
        "search"
    );

const abstractToggle =
    document.getElementById(
        "abstractToggle"
    );

const metaToggle =
    document.getElementById(
        "metaToggle"
    );

const columns =
    document.getElementById(
        "columns"
    );

const sort =
    document.getElementById(
        "sort"
    );

const reset =
    document.getElementById(
        "reset"
    );

const articlesElement =
    document.getElementById(
        "articles"
    );

const count =
    document.getElementById(
        "count"
    );

const empty =
    document.getElementById(
        "empty"
    );

const backTop =
    document.getElementById(
        "backTop"
    );


const histogram =
    document.getElementById(
        "histogram"
    );

const histogramAxis =
    document.getElementById(
        "histogramAxis"
    );

const histogramCount =
    document.getElementById(
        "histogramCount"
    );

const rangeMin =
    document.getElementById(
        "rangeMin"
    );

const rangeMax =
    document.getElementById(
        "rangeMax"
    );

const rangeSelected =
    document.getElementById(
        "rangeSelected"
    );

const readingRangeLabel =
    document.getElementById(
        "readingRangeLabel"
    );

const readingRangeText =
    document.getElementById(
        "readingRangeText"
    );

const rangeMinValue =
    document.getElementById(
        "rangeMinValue"
    );

const rangeMaxValue =
    document.getElementById(
        "rangeMaxValue"
    );

const readingReset =
    document.getElementById(
        "readingReset"
    );

const readingExplorer =
    document.getElementById(
        "readingExplorer"
    );

const readingToggle =
    document.getElementById(
        "readingToggle"
    );

const compactRange =
    document.getElementById(
        "compactRange"
    );

const compactHistogram =
    document.getElementById(
        "compactHistogram"
    );

const compactCount =
    document.getElementById(
        "compactCount"
    );

const activeFilters =
    document.getElementById(
        "activeFilters"
    );

const toolbar =
    document.querySelector(
        ".toolbar"
    );

const stickyStatus =
    document.getElementById(
        "stickyStatus"
    );

const stickyStatusFilters =
    document.getElementById(
        "stickyStatusFilters"
    );

const stickyStatusCount =
    document.getElementById(
        "stickyStatusCount"
    );


/* =========================================================
   Settings
========================================================= */

const DEFAULTS = {

    abstract: true,

    meta: true,

    maxColumns: "5",

    sort: "new",

    rangeMin: "0",

    rangeMax: "1000",

    readingCollapsed: false

};


const RANGE_SCALE_VERSION =
    "linear-v1";


const SORT_LABELS = {

    "new": "新しい順",

    "old": "古い順",

    "reading-asc": "読み応え:軽い順",

    "reading-desc": "読み応え:重い順"

};


const MIN_CARD_WIDTH =
    250;


const GRID_GAP =
    15;


/*
ヒストグラムbin数
*/

const HISTOGRAM_BINS =
    28;


/* =========================================================
   Reading distribution
========================================================= */

const READING_VALUES =
    ARTICLES
        .map(
            article =>
                article.reading_chars
        )
        .filter(
            value =>
                Number.isFinite(
                    value
                )
                &&
                value > 0
        );


let DATA_MIN =
    READING_VALUES.length
    ? Math.min(
        ...READING_VALUES
    )
    : 0;


let DATA_MAX =
    READING_VALUES.length
    ? Math.max(
        ...READING_VALUES
    )
    : 1;


/*
slider/histogramは実文字数に対する線形尺度で扱う。
*/


/* =========================================================
   Storage
========================================================= */

function loadSetting(
    key,
    fallback
) {

    try {

        const value =
            localStorage.getItem(
                "qiita-index-" + key
            );

        return (
            value === null
            ? fallback
            : value
        );

    } catch (_) {

        return fallback;

    }

}


function saveSetting(
    key,
    value
) {

    try {

        localStorage.setItem(
            "qiita-index-" + key,
            value
        );

    } catch (_) {

    }

}


/* =========================================================
   Reading conversion
========================================================= */

function sliderToChars(
    sliderValue
) {

    if (
        DATA_MAX === DATA_MIN
    ) {

        return DATA_MAX;

    }


    const t =
        Number(
            sliderValue
        )
        / 1000;


    return Math.round(
        DATA_MIN
        +
        t
        * (
            DATA_MAX
            - DATA_MIN
        )
    );

}


function charsToSlider(
    chars
) {

    if (
        DATA_MAX === DATA_MIN
    ) {

        return 0;

    }


    const t =
        (
            chars
            - DATA_MIN
        )
        /
        (
            DATA_MAX
            - DATA_MIN
        );


    return Math.max(
        0,
        Math.min(
            1000,
            Math.round(
                t * 1000
            )
        )
    );

}


/* =========================================================
   Number display
========================================================= */

function formatChars(
    value
) {

    if (
        value >= 10000
    ) {

        return (
            (
                value / 10000
            ).toFixed(
                value >= 100000
                ? 0
                : 1
            )
            + "万字"
        );

    }


    if (
        value >= 1000
    ) {

        return (
            (
                value / 1000
            ).toFixed(
                1
            )
            + "k字"
        );

    }


    return (
        value.toLocaleString()
        + ""
    );

}


/* =========================================================
   Normalize
========================================================= */

function normalize(text) {

    return String(
        text ?? ""
    )
        .toLowerCase()
        .normalize(
            "NFKC"
        );

}


function getTerms() {

    return normalize(
        search.value
    )
        .trim()
        .split(/\s+/)
        .filter(Boolean);

}


function articleSearchText(
    article
) {

    return normalize(
        [
            article.title,
            article.abstract,
            ...article.tags
        ].join(" ")
    );

}


/* =========================================================
   Highlight
========================================================= */

function appendHighlightedText(
    element,
    text,
    terms
) {

    element.textContent =
        "";

    if (
        terms.length === 0
    ) {

        element.textContent =
            text;

        return;

    }


    const lowerText =
        text.toLowerCase();

    const ranges =
        [];


    terms.forEach(
        term => {

            const rawTerm =
                term.toLowerCase();

            let position =
                0;


            while (true) {

                const index =
                    lowerText.indexOf(
                        rawTerm,
                        position
                    );


                if (
                    index === -1
                ) {

                    break;

                }


                ranges.push(
                    {
                        start:
                            index,

                        end:
                            index
                            + rawTerm.length
                    }
                );


                position =
                    index
                    + rawTerm.length;

            }

        }
    );


    if (
        ranges.length === 0
    ) {

        element.textContent =
            text;

        return;

    }


    ranges.sort(
        (a, b) =>
            a.start
            - b.start
    );


    const merged =
        [];


    ranges.forEach(
        range => {

            const last =
                merged[
                    merged.length - 1
                ];


            if (
                !last
                ||
                range.start
                > last.end
            ) {

                merged.push(
                    {
                        ...range
                    }
                );

            } else {

                last.end =
                    Math.max(
                        last.end,
                        range.end
                    );

            }

        }
    );


    let cursor =
        0;


    merged.forEach(
        range => {

            if (
                range.start
                > cursor
            ) {

                element.appendChild(
                    document.createTextNode(
                        text.slice(
                            cursor,
                            range.start
                        )
                    )
                );

            }


            const mark =
                document.createElement(
                    "mark"
                );


            mark.textContent =
                text.slice(
                    range.start,
                    range.end
                );


            element.appendChild(
                mark
            );


            cursor =
                range.end;

        }
    );


    if (
        cursor
        < text.length
    ) {

        element.appendChild(
            document.createTextNode(
                text.slice(
                    cursor
                )
            )
        );

    }

}


/* =========================================================
   Create card
========================================================= */

function createCard(
    article
) {

    const card =
        document.createElement(
            "article"
        );


    card.className =
        "article-card";


    card.dataset.search =
        articleSearchText(
            article
        );


    /* -----------------------------------------------------
       Title
    ----------------------------------------------------- */

    const h2 =
        document.createElement(
            "h2"
        );


    h2.className =
        "article-title";


    const link =
        document.createElement(
            "a"
        );


    link.href =
        article.url;


    link.target =
        "_blank";


    link.rel =
        "noopener noreferrer";


    const titleText =
        document.createElement(
            "span"
        );


    link.appendChild(
        titleText
    );


    const external =
        document.createElement(
            "span"
        );


    external.className =
        "external";


    external.textContent =
        "";


    link.appendChild(
        external
    );


    h2.appendChild(
        link
    );


    /* -----------------------------------------------------
       Abstract
    ----------------------------------------------------- */

    const abstract =
        document.createElement(
            "p"
        );


    abstract.className =
        "abstract";


    const aiLabel =
        document.createElement(
            "span"
        );


    aiLabel.className =
        "ai-label";


    aiLabel.textContent =
        "AI";


    const abstractText =
        document.createElement(
            "span"
        );


    abstract.appendChild(
        aiLabel
    );


    abstract.appendChild(
        abstractText
    );


    /* -----------------------------------------------------
       Meta
    ----------------------------------------------------- */

    const meta =
        document.createElement(
            "div"
        );


    meta.className =
        "meta";


    /* Tags */

    const tags =
        document.createElement(
            "div"
        );


    tags.className =
        "tags";


    article.tags.forEach(
        tag => {

            const button =
                document.createElement(
                    "button"
                );


            button.type =
                "button";


            button.className =
                "tag";


            button.textContent =
                tag;


            button.addEventListener(
                "click",
                () => {

                    search.value =
                        tag;


                    update();


                    search.focus();

                }
            );


            tags.appendChild(
                button
            );

        }
    );


    meta.appendChild(
        tags
    );


    /* -----------------------------------------------------
       Reading barometer
    ----------------------------------------------------- */

    const readingWeight =
        document.createElement(
            "div"
        );


    readingWeight.className =
        "reading-weight";


    const readingTop =
        document.createElement(
            "div"
        );


    readingTop.className =
        "reading-weight-top";


    const readingTitle =
        document.createElement(
            "span"
        );


    readingTitle.textContent =
        "読み応え";


    const readingName =
        document.createElement(
            "span"
        );


    readingName.className =
        "reading-weight-name";


    readingName.textContent =
        article.reading_label;


    readingTop.appendChild(
        readingTitle
    );


    readingTop.appendChild(
        readingName
    );


    const miniTrack =
        document.createElement(
            "div"
        );


    miniTrack.className =
        "reading-mini-track";


    const miniFill =
        document.createElement(
            "div"
        );


    miniFill.className =
        "reading-mini-fill";


    miniFill.style.width =
        article.reading_percentile
        + "%";


    const miniDot =
        document.createElement(
            "div"
        );


    miniDot.className =
        "reading-mini-dot";


    miniDot.style.left =
        article.reading_percentile
        + "%";


    miniTrack.appendChild(
        miniFill
    );


    miniTrack.appendChild(
        miniDot
    );


    const charLabel =
        document.createElement(
            "div"
        );


    charLabel.className =
        "reading-char-label";


    charLabel.textContent =
        formatChars(
            article.reading_chars
        )
        + "相当";


    readingWeight.appendChild(
        readingTop
    );


    readingWeight.appendChild(
        miniTrack
    );


    readingWeight.appendChild(
        charLabel
    );


    meta.appendChild(
        readingWeight
    );


    /* -----------------------------------------------------
       Date
    ----------------------------------------------------- */

    const dateRow =
        document.createElement(
            "div"
        );


    dateRow.className =
        "date-row";


    const time =
        document.createElement(
            "time"
        );


    time.textContent =
        article.date;


    dateRow.appendChild(
        time
    );


    meta.appendChild(
        dateRow
    );


    /* Build */

    card.appendChild(
        h2
    );


    if (
        article.abstract
    ) {

        card.appendChild(
            abstract
        );

    }


    card.appendChild(
        meta
    );


    return {

        article:
            article,

        element:
            card,

        titleText:
            titleText,

        abstractText:
            abstractText

    };

}


/* =========================================================
   Initial cards
========================================================= */

const CARD_DATA =
    ARTICLES.map(
        article =>
            createCard(
                article
            )
    );


CARD_DATA.forEach(
    item => {

        articlesElement.appendChild(
            item.element
        );

    }
);


/* =========================================================
   Histogram
========================================================= */

let HISTOGRAM_DATA =
    [];


let histogramSearchKey =
    null;


let histogramPopulationCount =
    ARTICLES.length;


function setDistributionBounds(
    population
) {

    const values =
        population
            .map(
                article =>
                    article.reading_chars
            )
            .filter(
                value =>
                    Number.isFinite(value)
                    &&
                    value > 0
            );


    if (
        values.length
    ) {

        DATA_MIN =
            Math.min(
                ...values
            );


        DATA_MAX =
            Math.max(
                ...values
            );

    } else {

        DATA_MIN =
            0;


        DATA_MAX =
            1;

    }

}


function buildHistogram(
    population = ARTICLES
) {

    HISTOGRAM_DATA =
        [];


    for (
        let i = 0;
        i < HISTOGRAM_BINS;
        i++
    ) {

        const t0 =
            i
            / HISTOGRAM_BINS;


        const t1 =
            (
                i + 1
            )
            / HISTOGRAM_BINS;


        const low =
            Math.round(
                DATA_MIN
                +
                t0
                * (
                    DATA_MAX
                    - DATA_MIN
                )
            );


        const high =
            Math.round(
                DATA_MIN
                +
                t1
                * (
                    DATA_MAX
                    - DATA_MIN
                )
            );


        HISTOGRAM_DATA.push(
            {
                low:
                    low,

                high:
                    high,

                count:
                    0,

                element:
                    null,

                compactElement:
                    null
            }
        );

    }


    population.forEach(
        article => {

            const value =
                article.reading_chars;


            let index =
                Math.floor(
                    charsToSlider(
                        value
                    )
                    / 1000
                    * HISTOGRAM_BINS
                );


            index =
                Math.max(
                    0,
                    Math.min(
                        HISTOGRAM_BINS - 1,
                        index
                    )
                );


            HISTOGRAM_DATA[
                index
            ].count++;

        }
    );


    const maxCount =
        Math.max(
            ...HISTOGRAM_DATA.map(
                bin =>
                    bin.count
            ),
            1
        );


    histogram.innerHTML =
        "";


    HISTOGRAM_DATA.forEach(
        bin => {

            const bar =
                document.createElement(
                    "div"
                );


            bar.className =
                "histogram-bar";


            const height =
                bin.count === 0
                ? 0
                : (
                    bin.count
                    / maxCount
                    * 100
                );


            bar.style.height =
                (
                    bin.count === 0
                    ? 2
                    : Math.max(
                        4,
                        height
                    )
                )
                + "%";


            bar.title =
                formatChars(
                    bin.low
                )
                + ""
                + formatChars(
                    bin.high
                )
                + ": "
                + bin.count
                + " articles";


            bin.element =
                bar;


            histogram.appendChild(
                bar
            );

        }
    );


    histogramPopulationCount =
        population.length;


    buildCompactHistogram(
        maxCount
    );


    buildAxis();

}


function buildCompactHistogram(
    maxCount
) {

    compactHistogram.innerHTML =
        "";


    HISTOGRAM_DATA.forEach(
        bin => {

            const bar =
                document.createElement(
                    "span"
                );


            const height =
                bin.count === 0
                ? 2
                : Math.max(
                    3,
                    bin.count
                    / maxCount
                    * 100
                );


            bar.style.height =
                height + "%";


            bin.compactElement =
                bar;


            compactHistogram.appendChild(
                bar
            );

        }
    );

}


function updateHistogramForSearch(
    terms
) {

    const key =
        terms.join(
            "\u0000"
        );


    if (
        key === histogramSearchKey
    ) {

        return false;

    }


    const isInitial =
        histogramSearchKey === null;


    histogramSearchKey =
        key;


    const population =
        ARTICLES.filter(
            article => {

                const text =
                    articleSearchText(
                        article
                    );


                return terms.every(
                    term =>
                        text.includes(
                            term
                        )
                );

            }
        );


    /*
    ヒストグラムの横軸・range sliderの尺度は、
    初期化時に全記事から決めた DATA_MIN / DATA_MAX を維持する。
    検索時は分布(bin count)のみ検索結果で再計算する。
    */

    buildHistogram(
        population
    );


    if (
        !isInitial
    ) {

        /*
        検索条件が変わっても現在の読み応えrangeは維持する。
        再描画したhistogramへ同じ選択範囲を反映するだけにする。
        */

        updateReadingRange(
            null,
            false
        );

    }


    return true;

}


/* =========================================================
   Axis
========================================================= */

function buildAxis() {

    histogramAxis.innerHTML =
        "";


    const representativeTicks = [
        { value: 500, label: "500" },
        { value: 1000, label: "1k" },
        { value: 2000, label: "2k" },
        { value: 5000, label: "5k" },
        { value: 10000, label: "10k" },
        { value: 20000, label: "20k" },
        { value: 50000, label: "50k" },
        { value: 100000, label: "10万" }
    ];


    const ticks = [
        {
            value: DATA_MIN,
            label: formatChars(DATA_MIN)
        }
    ];


    if (
        DATA_MAX > DATA_MIN
    ) {

        ticks.push(
            ...representativeTicks.filter(
                tick =>
                    tick.value > DATA_MIN
                    &&
                    tick.value < DATA_MAX
            ),
            {
                value: DATA_MAX,
                label: formatChars(DATA_MAX)
            }
        );

    }


    ticks.forEach(
        (tick, index) => {

            const label =
                document.createElement(
                    "span"
                );


            label.textContent =
                tick.label;


            label.style.left =
                charsToSlider(
                    tick.value
                )
                / 10
                + "%";


            if (
                index === 0
            ) {

                label.classList.add(
                    "axis-start"
                );

            }


            if (
                index
                === ticks.length - 1
            ) {

                label.classList.add(
                    "axis-end"
                );

            }


            histogramAxis.appendChild(
                label
            );

        }
    );

}


/* =========================================================
   Reading range
========================================================= */

function getReadingBounds() {

    const minSlider =
        Number(
            rangeMin.value
        );


    const maxSlider =
        Number(
            rangeMax.value
        );


    return {

        min:
            sliderToChars(
                minSlider
            ),

        max:
            sliderToChars(
                maxSlider
            )

    };

}


function updateHistogramSelection(
    bounds
) {

    HISTOGRAM_DATA.forEach(
        bin => {

            const overlaps =
                (
                    bin.high
                    >= bounds.min
                )
                &&
                (
                    bin.low
                    <= bounds.max
                );


            bin.element
                .classList
                .toggle(
                    "selected",
                    overlaps
                );


            bin.element
                .classList
                .toggle(
                    "dim",
                    !overlaps
                );


            if (
                bin.compactElement
            ) {

                bin.compactElement
                    .classList
                    .toggle(
                        "dim",
                        !overlaps
                    );

            }

        }
    );

}


function updateReadingRange(
    source = null,
    triggerUpdate = true
) {

    let minValue =
        Number(
            rangeMin.value
        );


    let maxValue =
        Number(
            rangeMax.value
        );


    /*
    ハンドルが交差しないようにする
    */

    const minimumGap =
        8;


    minValue =
        Math.max(
            0,
            Math.min(
                1000,
                minValue
            )
        );


    maxValue =
        Math.min(
            1000,
            Math.max(
                0,
                maxValue
            )
        );


    if (
        maxValue
        - minValue
        < minimumGap
    ) {

        if (
            source === "min"
        ) {

            minValue =
                Math.max(
                    0,
                    maxValue
                    - minimumGap
                );

        } else {

            maxValue =
                Math.min(
                    1000,
                    minValue
                    + minimumGap
                );


            if (
                maxValue
                - minValue
                < minimumGap
            ) {

                minValue =
                    maxValue
                    - minimumGap;

            }

        }

    }


    rangeMin.value =
        minValue;


    rangeMax.value =
        maxValue;


    const minPercent =
        minValue / 10;


    const maxPercent =
        maxValue / 10;


    rangeSelected.style.left =
        minPercent
        + "%";


    rangeSelected.style.right =
        (
            100
            - maxPercent
        )
        + "%";


    const bounds =
        getReadingBounds();


    if (
        minValue === 0
        &&
        maxValue === 1000
    ) {

        readingRangeText.textContent =
            "すべて";

    } else {

        readingRangeText.textContent =
            formatChars(
                bounds.min
            )
            + ""
            + formatChars(
                bounds.max
            );

    }


    rangeMinValue.textContent =
        formatChars(
            bounds.min
        );


    rangeMaxValue.textContent =
        formatChars(
            bounds.max
        );


    compactRange.textContent =
        readingRangeText.textContent;


    rangeMin.setAttribute(
        "aria-valuetext",
        formatChars(
            bounds.min
        )
    );


    rangeMax.setAttribute(
        "aria-valuetext",
        formatChars(
            bounds.max
        )
    );


    readingRangeLabel.title =
        "コード・数式等を除いた説明文量: "
        + formatChars(bounds.min)
        + ""
        + formatChars(bounds.max);


    /*
    Histogram selection
    */

    updateHistogramSelection(
        bounds
    );


    saveSetting(
        "rangeMin",
        String(
            minValue
        )
    );


    saveSetting(
        "rangeMax",
        String(
            maxValue
        )
    );


    if (
        triggerUpdate
    ) {

        update();

    }

}


/* =========================================================
   Columns
========================================================= */

function updateColumns() {

    const width =
        articlesElement
            .clientWidth;


    if (
        window.innerWidth
        <= 760
    ) {

        document
            .documentElement
            .style
            .setProperty(
                "--columns",
                1
            );


        document
            .documentElement
            .dataset
            .columns =
                "1";


        return;

    }


    const requested =
        Number(
            columns.value
        );


    const possible =
        Math.max(
            1,
            Math.floor(
                (
                    width
                    + GRID_GAP
                )
                /
                (
                    MIN_CARD_WIDTH
                    + GRID_GAP
                )
            )
        );


    const effective =
        Math.max(
            1,
            Math.min(
                requested,
                possible,
                10
            )
        );


    document
        .documentElement
        .style
        .setProperty(
            "--columns",
            effective
        );


    document
        .documentElement
        .dataset
        .columns =
            String(
                effective
            );

}


/* =========================================================
   Search + reading range + sort
========================================================= */

function compareDate(
    a,
    b,
    newestFirst = true
) {

    const ta =
        a.article.timestamp;


    const tb =
        b.article.timestamp;


    if (
        ta === null
        &&
        tb === null
    ) {

        return 0;

    }


    if (
        ta === null
    ) {

        return 1;

    }


    if (
        tb === null
    ) {

        return -1;

    }


    return newestFirst
        ? tb - ta
        : ta - tb;

}


function addFilterChip(
    container,
    label,
    onClear
) {

    const chip =
        document.createElement(
            "button"
        );


    chip.type =
        "button";


    chip.className =
        "filter-chip";


    chip.title =
        "クリックしてこの条件を解除";


    const text =
        document.createElement(
            "span"
        );


    text.textContent =
        label;


    const close =
        document.createElement(
            "span"
        );


    close.className =
        "chip-close";


    close.setAttribute(
        "aria-hidden",
        "true"
    );


    close.textContent =
        "×";


    chip.appendChild(
        text
    );


    chip.appendChild(
        close
    );


    chip.addEventListener(
        "click",
        onClear
    );


    container.appendChild(
        chip
    );

}


function updateActiveFilters(
    bounds,
    visible
) {

    activeFilters.innerHTML =
        "";


    stickyStatusFilters.innerHTML =
        "";


    const filters = [];


    const query =
        search.value.trim();


    if (
        query
    ) {

        filters.push({
            label: "検索: " + query,
            onClear: () => {

                search.value =
                    "";


                update();


                search.focus();

            }
        });

    }


    if (
        Number(rangeMin.value) !== 0
        ||
        Number(rangeMax.value) !== 1000
    ) {

        filters.push({
            label: "読み応え: "
            + formatChars(bounds.min)
            + ""
            + formatChars(bounds.max),
            onClear: () => {

                rangeMin.value =
                    0;


                rangeMax.value =
                    1000;


                updateReadingRange();

            }
        });

    }


    if (
        sort.value
        !== DEFAULTS.sort
    ) {

        filters.push({
            label: "並び順: "
            + SORT_LABELS[sort.value],
            onClear: () => {

                sort.value =
                    DEFAULTS.sort;


                saveSetting(
                    "sort",
                    DEFAULTS.sort
                );


                update();

            }
        });

    }


    filters.forEach(
        filter => {

            addFilterChip(
                activeFilters,
                filter.label,
                filter.onClear
            );


            addFilterChip(
                stickyStatusFilters,
                filter.label,
                filter.onClear
            );

        }
    );


    stickyStatusCount.textContent =
        visible
        + " / "
        + ARTICLES.length;


    activeFilters.classList.toggle(
        "show",
        activeFilters.childElementCount
        > 0
    );


    updateStickyStatusVisibility();

}


function updateStickyStatusVisibility() {

    const show =
        articlesElement
            .getBoundingClientRect()
            .top
        <=
        toolbar
            .getBoundingClientRect()
            .bottom
        + 4;


    stickyStatus.classList.toggle(
        "show",
        show
    );


    stickyStatus.setAttribute(
        "aria-hidden",
        String(!show)
    );

}

function update() {

    const terms =
        getTerms();


    updateHistogramForSearch(
        terms
    );


    const bounds =
        getReadingBounds();


    updateHistogramSelection(
        bounds
    );


    CARD_DATA.sort(
        (a, b) => {

            if (
                sort.value
                === "reading-asc"
            ) {

                return (
                    a.article.reading_chars
                    - b.article.reading_chars
                )
                || compareDate(a, b);

            }


            if (
                sort.value
                === "reading-desc"
            ) {

                return (
                    b.article.reading_chars
                    - a.article.reading_chars
                )
                || compareDate(a, b);

            }


            return compareDate(
                a,
                b,
                sort.value === "new"
            );

        }
    );


    let visible =
        0;


    CARD_DATA.forEach(
        item => {

            const textMatch =
                terms.every(
                    term =>
                        item
                            .element
                            .dataset
                            .search
                            .includes(
                                term
                            )
                );


            const value =
                item.article
                    .reading_chars;


            const readingMatch =
                value
                >= bounds.min
                &&
                value
                <= bounds.max;


            const show =
                textMatch
                &&
                readingMatch;


            item.element.style.display =
                show
                ? ""
                : "none";


            appendHighlightedText(
                item.titleText,
                item.article.title,
                terms
            );


            appendHighlightedText(
                item.abstractText,
                item.article.abstract,
                terms
            );


            articlesElement.appendChild(
                item.element
            );


            if (
                show
            ) {

                visible++;

            }

        }
    );


    count.textContent =
        visible
        + " / "
        + ARTICLES.length
        + " articles";


    histogramCount.textContent =
        "検索結果 "
        + histogramPopulationCount
        + "件の分布 · 選択範囲内 "
        + visible
        + "";


    compactCount.textContent =
        visible + "";


    updateActiveFilters(
        bounds,
        visible
    );


    empty.classList.toggle(
        "show",
        visible === 0
    );

}


/* =========================================================
   Toggle
========================================================= */

function setReadingCollapsed(
    collapsed
) {

    readingExplorer.classList.toggle(
        "collapsed",
        collapsed
    );


    readingToggle.setAttribute(
        "aria-expanded",
        String(!collapsed)
    );


    readingToggle.title =
        collapsed
        ? "読み応えフィルタを展開"
        : "読み応えフィルタを折りたたむ";


    saveSetting(
        "readingCollapsed",
        String(collapsed)
    );

}

function setAbstract(
    enabled
) {

    abstractToggle.checked =
        enabled;


    document.body
        .classList
        .toggle(
            "hide-abstract",
            !enabled
        );


    saveSetting(
        "abstract",
        String(
            enabled
        )
    );

}


function setMeta(
    enabled
) {

    metaToggle.checked =
        enabled;


    document.body
        .classList
        .toggle(
            "hide-meta",
            !enabled
        );


    saveSetting(
        "meta",
        String(
            enabled
        )
    );

}


/* =========================================================
   Events
========================================================= */

search.addEventListener(
    "input",
    update
);


abstractToggle.addEventListener(
    "change",
    () => {

        setAbstract(
            abstractToggle.checked
        );

    }
);


metaToggle.addEventListener(
    "change",
    () => {

        setMeta(
            metaToggle.checked
        );

    }
);


rangeMin.addEventListener(
    "input",
    () => {

        updateReadingRange(
            "min"
        );

    }
);


rangeMax.addEventListener(
    "input",
    () => {

        updateReadingRange(
            "max"
        );

    }
);


readingReset.addEventListener(
    "click",
    () => {

        rangeMin.value =
            0;


        rangeMax.value =
            1000;


        updateReadingRange();

    }
);


readingToggle.addEventListener(
    "click",
    () => {

        setReadingCollapsed(
            !readingExplorer
                .classList
                .contains(
                    "collapsed"
                )
        );

    }
);


columns.addEventListener(
    "change",
    () => {

        saveSetting(
            "maxColumns",
            columns.value
        );


        updateColumns();

    }
);


sort.addEventListener(
    "change",
    () => {

        saveSetting(
            "sort",
            sort.value
        );


        update();

    }
);


reset.addEventListener(
    "click",
    () => {

        search.value =
            "";


        rangeMin.value =
            0;


        rangeMax.value =
            1000;


        columns.value =
            DEFAULTS.maxColumns;


        sort.value =
            DEFAULTS.sort;


        setAbstract(
            DEFAULTS.abstract
        );


        setMeta(
            DEFAULTS.meta
        );


        saveSetting(
            "maxColumns",
            DEFAULTS.maxColumns
        );


        saveSetting(
            "sort",
            DEFAULTS.sort
        );


        updateReadingRange();


        updateColumns();


        update();


        search.focus();

    }
);


/* =========================================================
   Search shortcut
========================================================= */

document.addEventListener(
    "keydown",
    event => {

        if (
            event.key === "/"
            &&
            document.activeElement
            !== search
        ) {

            event.preventDefault();


            search.focus();

        }

    }
);


/* =========================================================
   Back to top
========================================================= */

window.addEventListener(
    "scroll",
    () => {

        backTop.classList.toggle(
            "show",
            window.scrollY > 500
        );


        updateStickyStatusVisibility();

    }
);


backTop.addEventListener(
    "click",
    () => {

        window.scrollTo(
            {
                top: 0,
                behavior: "smooth"
            }
        );

    }
);


/* =========================================================
   Resize
========================================================= */

if (
    "ResizeObserver"
    in window
) {

    const observer =
        new ResizeObserver(
            () => {

                updateColumns();

            }
        );


    observer.observe(
        articlesElement
    );

}


window.addEventListener(
    "resize",
    () => {

        updateColumns();


        updateStickyStatusVisibility();

    }
);


/* =========================================================
   Reset conditions on every page load
========================================================= */

/*
再読み込み時は前回の検索・絞り込み・並び順・表示設定を復元せず、
常にDEFAULTSから開始する。
ブラウザがフォーム値を自動復元する場合にも備えて明示的に初期化する。
*/

search.value =
    "";


setAbstract(
    DEFAULTS.abstract
);


setMeta(
    DEFAULTS.meta
);


setReadingCollapsed(
    DEFAULTS.readingCollapsed
);


columns.value =
    DEFAULTS.maxColumns;


sort.value =
    DEFAULTS.sort;


rangeMin.value =
    DEFAULTS.rangeMin;


rangeMax.value =
    DEFAULTS.rangeMax;


/*
既存のlocalStorageに古い条件が残っていても次回以降参照されないが、
内容もDEFAULTSへ揃えておく。
*/

saveSetting(
    "abstract",
    String(DEFAULTS.abstract)
);


saveSetting(
    "meta",
    String(DEFAULTS.meta)
);


saveSetting(
    "maxColumns",
    DEFAULTS.maxColumns
);


saveSetting(
    "sort",
    DEFAULTS.sort
);


saveSetting(
    "rangeMin",
    DEFAULTS.rangeMin
);


saveSetting(
    "rangeMax",
    DEFAULTS.rangeMax
);


saveSetting(
    "readingCollapsed",
    String(DEFAULTS.readingCollapsed)
);


saveSetting(
    "rangeScale",
    RANGE_SCALE_VERSION
);


/* =========================================================
   Initial render
========================================================= */

updateReadingRange();


requestAnimationFrame(
    updateColumns
);

</script>

</body>

</html>
"""

    # ========================================================
    # Replace
    # ========================================================

    html = html.replace(
        "__PAGE_TITLE__",
        page_title,
    )

    html = html.replace(
        "__AUTHOR_HTML__",
        author_html,
    )

    html = html.replace(
        "__ARTICLE_COUNT__",
        str(
            len(articles)
        ),
    )

    html = html.replace(
        "__ARTICLES_JSON__",
        articles_json,
    )

    # ========================================================
    # Save
    # ========================================================

    output_path.write_text(
        html,
        encoding="utf-8",
    )

    # ========================================================
    # Summary
    # ========================================================

    level_counts = (
        pd.Series(
            [
                article[
                    "reading_label"
                ]
                for article
                in articles
            ]
        )
        .value_counts()
    )

    reading_values = [
        article[
            "reading_chars"
        ]
        for article
        in articles
    ]

    print(
        f"入力CSV : {csv_path}"
    )

    print(
        f"版       : {SCRIPT_VERSION}"
    )

    print(
        f"記事数   : {len(articles)}"
    )

    print(
        f"出力HTML: {output_path}"
    )

    print(
        "\n読み応え:"
    )

    for label in [
        "軽め",
        "標準",
        "多め",
        "たっぷり",
    ]:

        print(
            f"  {label}: "
            f"{level_counts.get(label, 0)}"
        )

    if reading_values:

        s = pd.Series(
            reading_values
        )

        print(
            "\n説明文量(コード・数式等を除く):"
        )

        print(
            f"  median : {int(s.median()):,}"
        )

        print(
            f"  25%    : {int(s.quantile(0.25)):,}"
        )

        print(
            f"  75%    : {int(s.quantile(0.75)):,}"
        )

        print(
            f"  max    : {int(s.max()):,}"
        )


if __name__ == "__main__":
    main()

4. HTMLを公開する

生成したHTMLは、そのままローカルで開いて使えます。

Web上で公開したい場合は、GitHub Pagesなどの静的ホスティングサービスや、自分で管理しているWebサーバーなど、各自の環境に合わせて公開すれば完成です。

おわりに

今回は、増えてきた自分のQiita記事を探しやすくするために、AI Abstractと「読み応え」を付けて一覧化してみました。

タイトルやタグだけでなく、「何が書いてあるか」と「どれくらいの量か」が入口で分かるだけでも、自分で記事を探すときにも、誰かに紹介するときにも結構便利です。

AI Abstractで「読みたい記事か」を判断して、「読み応え」で「今読むか」を判断する。

記事一覧にも、こういう情報があっても面白いのかもしれません。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?