クソ記事チェッカー

:::note

一旦サーバー止めています。
コードのミスがみられ、デプロイまだしていないので申し訳ありません。

:::

## タイトルにつられましたね？

過激なタイトルで集客してしまいました。ご容赦ください。

本記事は`Python`で`FastAPI`と`LINE Messaging API`を活用し、自身の執筆したQiita記事の構成を見直すLINE Botを作成したものとなります。

- 拡散性の低いタグがついている
- 段落の構成が誤っている
- シンタックスハイライトが狂っている

等記事の内容と無関係な本質的でない部分で悪印象を持たれてしまい、結果として有用な内容であっても本文を読まれることなく埋もれてしまう記事が多く存在するのではないかと考えました。

**そこで、簡易的な項目についてチェックしてもらい記事の体裁を整えることに資する、LINE Botのプロトタイプを作成しました。**

::: note warn
本記事は良い情報であっても、**積極的に発信しにいかなければ読まれないため非常に勿体無い**というスタンスに立って作成しています。
そのため、拡散性を高めるという観点での機能を主に実装しておりますので、皆さまそれぞれの意図を持って、コードを改変・利用いただけますと幸いです。
:::

## 機能一覧

### タグの判定
- タグの個数カウント。目安とて5つ付けましょう。
- 拡散性の低いタグの指摘。参考としてフォロワーが200に満たないタグをアラートします。**技術的知見が薄い領域の記事を書いている場合など特別な意図がない限り**、タグを再検討することが無難です。
- `#` の有無。Qiitaのタグは # が不要です。意外と間違えやすいのでチェックしてもらいます。

### コードブロック
- シンタックスハイライトの言語を表示してもらいます。

### 段落構成

- `#` 1この段落がないか。細かいことですが、# 1個はH1 tag、すなわちタイトル（この記事で言えば`クソ記事チェッカー`に相当するので、Qiita記事を書くときは ## から始めましょう。
    - これに関しては厳密な規定などがあるわけではありませんので、不要と思えば削除してしまって構いません。
- 段落構成が狂っていないか。## の下に ####がきているなどが発生していないか。H2 tagの下に H3すっ飛ばしてH4あったら気分悪いですよね。

## 利用方法

LINE Botにしてみました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/98e549fb-2b72-2062-50ad-dce52bb74a2f.png" width=25%>

デプロイしたらBot上で通知します。

**チェックしてみたいQiita記事のURLをLINEで送るだけで簡単に結果を送ってきてくれます。**

今回のサンプル記事はこちら

https://qiita.com/watanabe-tsubasa/private/a275452b1c88993d5690

![スクリーンショット 2023-04-28 3.25.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/1a5ef2bd-da6f-9cd0-f9fe-b577fa477e6a.png)

この画像のように、フィードバックしてくれます。

## 開発環境

> MacOS version 12.6
> Python 3.11.3
> ngrok 2.3.40
> Pythonのライブラリ関連は`Requirements.txt`参照
 <details><summary>Requirements.txt</summary>
 
```text:Requirements.txt
aiohttp==3.8.4
aiosignal==1.3.1
anyio==3.6.2
async-timeout==4.0.2
attrs==23.1.0
certifi==2022.12.7
charset-normalizer==3.1.0
click==8.1.3
fastapi==0.95.1
frozenlist==1.3.3
future==0.18.3
h11==0.14.0
idna==3.4
line-bot-sdk==2.4.2
multidict==6.0.4
pydantic==1.10.7
requests==2.28.2
sniffio==1.3.0
starlette==0.26.1
typing_extensions==4.5.0
urllib3==1.26.15
uvicorn==0.21.1
yarl==1.9.2
```

> </details>

## システム構成 / コード

ベースの作り方はこちらの記事を参照してください。

https://qiita.com/watanabe-tsubasa/items/44ccec1763f2cfb678b9

```text
.
├── README.md
├── main.py
├── requirements.txt
└── src
    └── handle_markdown.py
```

<details><summary>main.py</summary>

```python:main.py
from distutils.log import error
from fastapi import FastAPI, Request
import logging
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage
from dotenv import load_dotenv; load_dotenv()
import uvicorn
import os
import requests
from src.handle_markdown import HandleParagraph, HandleTagEvent, HandleCodeBlock, HandleTagEvent

CHANNEL_SECRET = os.environ.get('CHANNEL_SECRET') or 'CHANNEL_SECRET'
CHANNEL_ACCESS_TOKEN = os.environ.get('CHANNEL_ACCESS_TOKEN') or 'CHANNEL_ACCESS_TOKEN'

app = FastAPI()

line_bot_api = LineBotApi(channel_access_token=CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(channel_secret=CHANNEL_SECRET)

logger = logging.getLogger(__name__)

@app.post("/callback")
async def callback(request: Request):
    signature = request.headers['X-Line-Signature']
    
    body = await request.body()
    logger.info("Request body:" + body.decode())
    
    try:
        handler.handle(body.decode(), signature)
    except InvalidSignatureError:
        logger.warning("Invalid signature")
        return "Invalid signature"
    
    return "OK"

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if event.message.text.startswith("https://qiita.com"):
        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text='記事を判定します。少々お待ちください。')
        )
        try:
            # botに入力されたQiita記事のmarkdownを取得
            res = requests.get(event.message.text + '.md')
            article_text = res.content.decode(res.encoding)
            
            # タグについて
            tag_handler = HandleTagEvent(article_text)
            line_bot_api.push_message(
                event.source.user_id,
                TextSendMessage(text=tag_handler.count_tag())
            )
            tag_list = tag_handler.get_tag_list()
            for tag in tag_list:
                message = tag_handler.validate_tag_info(tag)
                if message != 'is_collect':
                    line_bot_api.push_message(
                        event.source.user_id,
                        TextSendMessage(text=message)
                    )
            
            # コードブロックの部分について
            code_handler = HandleCodeBlock(article_text)
            code_block_list = code_handler.get_code_block()
            if code_block_list != 'no_code':
                for i, match in enumerate(code_block_list):
                    message = code_handler.validate_code_lang(i, match)
                    line_bot_api.push_message(
                        event.source.user_id,
                        TextSendMessage(text=message)
                    )
            
            # 段落構成の部分について
            paragraph_handler = HandleParagraph(article_text)
            if paragraph_handler.is_contain_h_one() != 'is_collect':
                message = paragraph_handler.is_contain_h_one()
                line_bot_api.push_message(
                        event.source.user_id,
                        TextSendMessage(text=message)
                    )
            if paragraph_handler.is_corrupted_paragraph() != 'is_collect':
                message = paragraph_handler.is_corrupted_paragraph()
                line_bot_api.push_message(
                        event.source.user_id,
                        TextSendMessage(text=message)
                    )
            
            line_bot_api.push_message(
                event.source.user_id,
                TextSendMessage(text='簡易フィードバックが完了しました', )
            )
            
        except error:
            line_bot_api.push_message(
                event.source.user_id,
                TextSendMessage(text=f'エラーが発生しました。事務局まで連絡してください。\n{error}', )
            )
    else:
        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text='Qiita記事のURLを送信してください')
        )
    
if __name__ == "__main__":
    uvicorn.run("app:app", host="0.0.0.0", port=8000, log_level='info')

```

</details>

- サーバーはFastAPIで実装しています。
- Qiita記事のURL末尾に`.md`をつけるとmarkdownが返されることを利用し、Pythonのrequestsで記事のmarkdownをgetしています。
- 取得したmarkdownをこの後に記載するhandle_markdown.pyで処理し、その結果をベースにLINE Botに返すreplymessageを生成しています。

<details><summary>handle_markdown.py</summary>

```python:handle_markdown.py
import re
import requests

class HandleTagEvent:
    
    def __init__ (self, markdown_text:str):
        self.markdown_text = markdown_text

    def get_tag_list(self):
        regex = r'tags:\s*([^\n]+)\n'
        m = re.search(regex, self.markdown_text, re.DOTALL)
        if m:
            tag_list:list = m.group(1).strip().split(' ')
        else:
            tag_list:list = []
        
        return tag_list

    def validate_tag_info(self, tag:str):
        if '#' in tag:
            message = 'Qiitaではタグ名に # は不要なので修正してください'
        else:
            try:
                tag_follower_count = requests.get(f'https://qiita.com/api/v2/tags/{tag}').json()['followers_count']
            except:
                tag_follower_count = 0
            if tag_follower_count < 200:
                message = f'{tag}はフォロワー数が200を下回るタグですが利用しますか'
            else :
                message = 'is_collect'
        
        return message
            
    def count_tag(self):
        tag_list = self.get_tag_list()
        message = f'記事のタグ数は{len(tag_list)}です。'
        if len(tag_list) < 5:
            message += '\nタグはできる限り5つつけましょう'
        
        return message
    

class HandleCodeBlock:

    def __init__ (self, markdown_text: str):
        self.markdown_text = markdown_text
        
    def get_code_block(self):
        regex = r"```(?P<lang>\w+)?\n(?P<code>.*?)\n```"
        matches = re.findall(regex, self.markdown_text, re.DOTALL)
        return matches if matches else "no_code"
        
    def validate_code_lang(self, i:int, match: re.match):
        lang = match[0]
        if lang == '':
            message = f'{i + 1}番目のコードブロックには言語が指定されていません。\nシンタックスハイライトが有効になるよう、適切なコードを指定しましょう。'
        else:
            message = f'{i + 1}番目のコードブロックに指定されているシンタックスハイライトは{lang}です。'
            if lang not in ['javascript', 'js']:
                message += '\n正しいかを確認しましょう。'
            if lang == 'java':
                message += '\n特にJavaとJavaScriptは、ハムとハムスター位違いますよ。注意しましょう。'

        return message            

class HandleParagraph:
    
    def __init__ (self, markdown_text):
        self.markdown_text = markdown_text

    def remove_code_block(self):
        # コードブロックのパターンを定義する
        regex = r"```[\w\s]*\n([\s\S]*?)\n```"
        # コードブロックを除去する
        return re.sub(regex, "", self.markdown_text, flags=re.DOTALL)

    def count_sharp(self):
        # 各段落の # の数をリストに格納する
        regex = r"^(#+)(?!#)(.*)$"
        headings = []
        text_without_code_blocks = self.remove_code_block()
        
        for line in text_without_code_blocks.split("\n"):
            match = re.match(regex, line)
            if match:
            # コードブロック中の # を除外するため、# の前後にスペースを付与する
                heading = match.group(1).strip()
                headings.append(len(heading))
        return headings

    def is_contain_h_one(self):
        headings = self.count_sharp()

        if any(x == 1 for x in headings) :
            message = '段落のマークダウンは ## からはじめるようにしましょう\n # 1つはページ全体を表すため記事には利用しません'
            return message
        else:
            return 'is_collect'

    def is_corrupted_paragraph(self):
        headings = self.count_sharp()

        count = 0
        for i in range(len(headings) - 1):
            if headings[i + 1] - headings[0] > 1:
                count += 1
        if count > 0:
            message = f'段落構成が崩れている箇所が{count}箇所あります\n # は1つずつ増やしましょう'
            return message
        else:
            return 'is_collect'
```

</details>

- markdownを判定する処理を記述しています。

::: note warn
`handle_markdown.py`の記事判定アルゴリズムの部分を実装し、ターミナルで実行結果をチェックする意図で作っていたものを無理にLINE Botに乗せたため、機能ごとのファイル切り分けが綺麗にできていません。本格的に運用したいのであれば、リファクタリングが必要です。
:::

## 追記　この記事はどうなのか
![スクリーンショット 2023-04-28 3.47.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/3da31c71-ef1d-554a-3201-4959a76ced27.png)

### FastAPIタグに関する指摘

- FastAPIのフォロワーは少ないですが、新しいライブラリであり記事が少なく、今後成長領域かと思いますので、利用していきましょう

### コードブロックに関する指摘

- コードブロックで指定している言語がうまく取れていないようです🤪