👓

PythonでZennの記事を取得してマークダウン化する

2024/05/22に公開

執筆日

2024/05/22
Topics検索以外の検索についての修正
2024/06/04

本記事のスクリプトを使う際の注意

Zennの利用規約に注意して運営の妨害にならないように気を付けましょう。
記事執筆時の2023年06月07日改定版時点では、第4条(禁止事項)でプログラムを使用してデータを取得する行為を直接は禁止していないようです。ただし、大量のリクエストを送る行為は「3. 運営者のサーバーまたはネットワークの機能を破壊したり、妨害したりする行為」や「4. 本サービスの運営を妨害する行為、または妨害するおそれのある行為」等に当たる可能性があるため十分に注意してください。(データスクレイピングしてLLMの学習に使う、みたいなことはこれに当たる可能性があります)

概要

以下のようなことに興味があったので作ってみました。

  • 好きなTopicの記事を半自動的に取得して一日一本読む
  • 読んだ記事をデータ化して自分の知識データベースとして蓄積する
  • 内容を取得してLLMに要約させる

勉強のために日課としてなんか読みたいけど一々記事選ぶのめんどくさい、読んだ記事をまた探すのがめんどくさい、正直まじめに読むのもめんどくさい、というめんどくさがりの書いた記事です。このコードを書くのが一番めんどくさくなるとは……。
Headwatersは社員の学びのアウトプット先としてZenn記事投稿に力を入れているのですが、数が多くて社内ですらどんな記事が存在しているか把握しきれていません。また、publicationのページからは記事検索ができないのでctrl+Fでページ内検索するくらいしか方法がありません(たぶん)。そこで記事のDBがあれば便利かなとか思っています。
「Zennの記事元々マークダウンじゃん」「GPTに要約させるならURL投げるだけでいいんじゃない?」等のツッコミをもらうと泣くかもしれません。ところでGPTってURL投げたときどうやって中身を読んでるんでしょう、HTMLでも読めると思うけどトークン数に無駄がありそう。

Note

  • 最初にも書きましたが、Zennの運営を妨害しない範囲でおねがいします
  • keywordsとpublicationの検索が上手くいっていないことに公開してから気づきました。ごめんなさい(修正完了)
    • キーワード検索と団体名検索ではページ情報の逐次取得が入るので、requestsだと上手く情報取得できなかったためseleniumを使ってブラウザの自動操作を使うようにしました。またHTMLタグも違うものを使ってクラス化していたのでその辺りの対応も必要でした。
  • 記事執筆時点のZennのHTML仕様を調べて書いたものなので仕様が変更されると使えなくなります
  • プログラムを作る際に使ったサンプル記事(30記事ほどでチェック)に含まれていないタグの仕様については処理ができません
    • Youtubeなどの外部サービスの埋め込みは未確認ですが気が向いたら追記したいです
    • コードブロックのシンタックスハイライト対応言語は一部のみです
    • 脚注の処理は諦めました

依存ライブラリインストール

requestsseleniumbs4を使ってHTMLをゴリゴリ処理していきます。

pip install requests beautifulsoup4 selenium

プログラム

pythonスクリプト

しっかりめにコメントを書いたので読めるものになっていると思いたいです。

get_zenn_articles.py
import time

import requests
from selenium import webdriver
from bs4 import BeautifulSoup


def search_zenn_articles(keywords=[], topics=None, publication=None, page=1, trend=False, latest=False, popular=False):
    """Zennの記事を検索して、記事のタイトル、著者、URLを取得する関数
    Args:
        keywords (list): 検索キーワードのリスト
        topics (str): 検索トピックス
        publication (str): 出版団体名
        page (int): 検索結果のページ数
        trend (bool): トレンド順に検索結果を表示
        latest (bool): 新規順に検索結果を表示
        popular (bool): 人気(いいね数)順に検索結果を表示
    Returns:
        articles (list): 記事のタイトル、著者、URLのリスト
    Notes:
        publication(出版団体)の場合はその他のオプションは無効
    """
    # 検索方法の選択(複数選択していても上から引っかかったもので検索する)
    target = None
    if topics:
        url = f"https://zenn.dev/topics/{topics}?page={page}"
        target = "topics"
    elif keywords:
        url = f'https://zenn.dev/search?q={"+".join(keywords)}&page={page}'
        target = "keywords"
    elif publication:
        url = f"https://zenn.dev/p/{publication}"
        target = "publication"
    else:
        return
    # 取得順の選択
    if trend:
        url += "&order=daily"
    elif latest:
        url += '&order=latest'
    elif popular:
        url += '&order=alltime'

    # 記事の情報を取得
    articles = []
    if target == "topics":
        articles = search_articles_from_topics(url)
    elif target == "keywords":
        articles = search_articles_from_keywords(url)
    elif target == "publication":
        articles = search_articles_from_publication(url)

    return articles

def search_articles_from_topics(url):
    # 検索結果の取得
    response = requests.get(url)
    response.raise_for_status()
    soup = BeautifulSoup(response.text, 'html.parser')
    articles = []
    for article in soup.find_all('div', class_='ArticleList_itemContainer__UNI2Y'): # 各記事のdivを取得
        title_tag = article.find('h2', class_='ArticleList_title__mmSkv') # タイトルのタグを取得
        link_tag = article.find('a', class_='ArticleList_link__4Igs4') # リンクのタグを取得
        user_tag = article.find('div', class_='ArticleList_userName__MlDD5').find('a') # ユーザー名のタグを取得
        # それぞれのタグから記事情報を取得
        title = title_tag.text.strip()
        user_name = user_tag.text.strip()
        link = link_tag['href']
        article_link = f'https://zenn.dev{link}'
        author_link = f'https://zenn.dev/{link.split("/")[1]}'
        articles.append({'title': title, 'user': user_name ,'link': article_link, 'author_link': author_link})
    return articles

def search_articles_from_keywords(url):
    # 検索結果の取得
    html = get_html_with_selenium(url)
    soup = BeautifulSoup(html, 'html.parser')
    article_containers = soup.find_all('div', class_='ArticleList_itemContainer__UNI2Y')
    articles = []
    for article in article_containers:
        # タイトルを抽出
        title_tag = article.find('h2', class_='ArticleList_title__mmSkv')
        title = title_tag.text.strip() if title_tag else None
        # URLを抽出
        url_tag = article.find('a', class_='ArticleList_link__4Igs4')
        url = url_tag['href'] if url_tag else None
        article_url = f'https://zenn.dev{url}' if url_tag else None
        # ユーザーネームを抽出
        user_tag = article.find('div', class_='ArticleList_userName__MlDD5')
        user_name = user_tag.text.strip() if user_tag else None
        # ユーザーURLを抽出
        user_url_tag = user_tag.find('a')
        user_url = f'https://zenn.dev{user_url_tag["href"]}' if user_url_tag else None

        articles.append({'title': title, 'user': user_name ,'link': article_url, 'author_link': user_url})
    
    return articles

def search_articles_from_publication(url):
    # 検索結果の取得
    html = get_html_with_selenium(url)
    soup = BeautifulSoup(html, 'html.parser')
    article_containers = soup.find_all('article', class_='ArticleCard_container__duCK7')
    articles = []
    for article in article_containers:
        # タイトルを抽出
        title_tag = article.find('h3', class_='ArticleCard_title__Y2xJl')
        title = title_tag.text.strip() if title_tag else None
        # URLを抽出
        url_tag = article.find('a', class_='ArticleCard_mainLink__mLJti')
        url = url_tag['href'] if url_tag else None
        article_link = f'https://zenn.dev{url}' if url_tag else None
        # ユーザーネームを抽出
        user_tag = article.find('div', class_='ArticleCard_userName__W3PTU')
        user_name = user_tag.text.strip() if user_tag else None
        # ユーザーURLを抽出
        user_url_tag = article.find('a', class_='ArticleCard_user__NjWz3')
        author_link = f'https://zenn.dev{user_url_tag["href"]}' if user_url_tag else None

        articles.append({'title': title, 'user': user_name ,'link': article_link, 'author_link': author_link})
    
    return articles

def get_html_with_selenium(url):
    # Chromeドライバーの設定
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')
    driver = webdriver.Chrome(options=options)
    driver.get(url)

    # ページをスクロールしてコンテンツをロード
    last_height = driver.execute_script("return document.body.scrollHeight")
    while True:
        driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
        time.sleep(2)  # コンテンツがロードされるのを待つ
        new_height = driver.execute_script("return document.body.scrollHeight")
        if new_height == last_height:
            break
        last_height = new_height

    # ページのHTMLを取得
    html = driver.page_source
    driver.quit()

    return html


def get_article_content(article_url):
    """Zennの記事のURLから本文を取得する関数
    Args:
        article_url (str): 記事のURL
    Returns:
        content (str): 記事の本文
    """
    # 記事本文のdivを取得
    response = requests.get(article_url)
    soup = BeautifulSoup(response.text, 'html.parser') 
    content_div = soup.find('div', class_='BodyContent_anchorToHeadings__uGxNv') 
    if not content_div:
        return None

    # 本文のマークダウン化
    content_lines = convert_html_to_md(content_div)
    content = '\n'.join(content_lines)
    # 余分な改行の削除(設計が悪いためですごめんなさい)
    while True:
        content = content.replace('\n\n\n', '\n\n')
        if content.find('\n\n\n') == -1:
            break
    content = content.replace('- \n', '- ')
    
    return content

def convert_html_to_md(content_div, indent=0):
    """
    HTML形式の記事本文をMarkdown形式に変換する関数
    Args:
        content_div (bs4.element.Tag): 本文のdiv要素
        indent (int): リストのインデント
    Returns:
        content_lines (list): Markdown形式の本文のリスト
    Notes:
        実装している処理: 見出し(h1~h6)、パラグラフ(p)、リスト(ol,ul,li)、引用(blockquote, aside)、
                        コードブロック(div class=code-block-container)、テーブル(table)、インラインコード(code)、
                        リンク(a)、強調テキスト(strong)、画像(img)、改行(br)、水平線(hr)、
        実装を諦めた処理: 脚注(sup class=footnote-ref, section class=footnotes)
    """
    content_lines = []
    
    for child in content_div.children:
        content_text = ''
        # 見出しの処理
        if child.name == 'h1':
            content_text = "# " + child.text.strip()
        elif child.name == 'h2':
            content_text = "## " + child.text.strip()
        elif child.name == 'h3':
            content_text = "### " + child.text.strip()
        elif child.name == 'h4':
            content_text = "#### " + child.text.strip()
        elif child.name == 'h5':
            content_text = "##### " + child.text.strip()
        elif child.name == 'h6':
            content_text = "###### " + child.text.strip()       
        # コードブロックの処理
        elif child.name == 'div' and 'code-block-container' in child.get('class', []):
            content_text = add_code_block_to_text(child)
        # パラグラフの処理(再帰処理)    
        elif child.name == 'p':
            paragraph_elements = convert_html_to_md(child, indent=indent)
            content_text = " ".join(paragraph_elements)
            content_text += "  \n"
        # 折り畳みの展開処理
        elif child.name == 'details':
            summary = child.find('summary')
            if summary:
                summary_text = summary.text.strip()
                content_text = f'**{summary_text}**  \n'
            details = child.find('div', class_='details-content')
            if details:
                details_elements = convert_html_to_md(details, indent=indent)
                content_text += " ".join(details_elements)
        # リストの処理(再帰処理)
        elif child.name == "ol" or child.name == "ul":
            indent += 1
            content_lines.extend(convert_html_to_md(child, indent=indent))
            indent -= 1
        elif child.name == 'li':
            list_elements = convert_html_to_md(child, indent=indent)
            if indent > 0:
                indent_space = " " * (indent - 1) * 2
            else:
                indent_space = ""
            content_text = " ".join(list_elements)
            content_text = '\n' + indent_space + '- ' + content_text + '  \n'
        # 引用の処理
        elif child.name == 'blockquote':
            quote_elements = convert_html_to_md(child, indent=indent)
            content_text = " ".join(quote_elements)
            content_text = content_text.replace("\n", "\n> ")
            content_text = f'> {content_text}\n'
        elif child.name == 'aside':
            child = child.find('div', class_='msg-content')
            aside_elements = convert_html_to_md(child, indent=indent)
            content_text = " ".join(aside_elements)
            content_text = content_text.replace("\n", "\n> ")
            content_text = f'> **! Note !**\n> \n> {content_text}\n'
        # テーブルの処理
        elif child.name == 'table':
            content_text = convert_table_to_markdown(child)
        # インラインコードの処理
        elif child.name == 'code':
            content_text = f'`{child.text}`'
        # リンク付きテキストの処理
        elif child.name == 'a':
            content_text = add_link_to_text(child)
        # 数式の処理 (マクロを使った数式は未対応・VScodeの拡張機能で一部コードブロックが上手くレンダリングできなくなる不具合があるかも)
        elif child.name == 'embed-katex':
            content_text = f'${child.text}$'
        elif child.name == 'section' and 'zenn-katex' in child.get('class', []):
            content_text = f'$${child.text}$$'
        # 強調テキストの処理
        elif child.name == 'strong':
            content_text = f"**{child.text}**"
        # 取り消し線の処理
        elif child.name == 's':
            content_text = f"~~{child.text}~~"
        # 画像の処理
        elif child.name == 'img':
            img_src = child.get('src')
            content_text = f'\n![{img_src}]({img_src})\n'
        # 画像キャプションの処理
        elif child.name == 'em':
            content_text = f'*Image Caption: {child.text}*'
        # 改行の処理
        elif child.name == 'br':
            content_text = '  \n'
        # 水平線の処理
        elif child.name == 'hr':
            content_text = '\n---\n'
        # テキストノードの処理
        elif isinstance(child, str):
            content_text = child.strip()
        # それ以外のタグの処理
        else:
            # デバッグ用
            if not ((child.name == "span" and "embed-block" in child.get("class", [])) or # 埋め込みブロックはURLだけ取り出せているので除外
                    (child.name == "sup" and "footnote-ref" in child.get("class", [])) or # 脚注は未対応
                    (child.name == "section" and "footnotes" in child.get("class", [])) # 脚注は未対応
                    ):
                print('Unknown tag:', child.name, '\nclass:', child.get('class', []), "\ntext:", child.text)
            content_text = child.text.strip()

        if content_text:
            content_lines.append(content_text)

    return content_lines

def add_link_to_text(text_link):
    """
    リンク付きテキストの処理を行う関数
    Args:
        text_link (bs4.element.Tag): リンク付きテキストの要素
    Returns:
        content_text (str): マークダウン形式のリンク付きテキスト
    """
    if "header-anchor-link" in text_link.get("class", []):
        content_text = text_link.text
    else:
        link_text = text_link.text
        link_href = text_link.get('href')
        content_text = f'[{link_text}]({link_href})'
    return content_text

def add_code_block_to_text(code_block):
    """
    コードブロックの処理を行う関数
    Args:
        code_block (bs4.element.Tag): コードブロックの要素
    Returns:
        content_text (str): マークダウン形式のコードブロック
    TODO:
        未対応言語の処理の追加
    """

    # ファイル名の取得
    filename_container = code_block.find('div', class_='code-block-filename-container')
    if filename_container:
        filename_container = filename_container.find('span', class_='code-block-filename')
        filename_text = f":{filename_container.text.strip()}"
    else:
        filename_text = ""

    # コードブロックの取得
    code_block = code_block.find('pre').find('code')
    # 言語の取得
    code_block_classes = code_block.get('class', [])
    if "language-shell" in code_block_classes:
        language = "shell"
    elif "language-bash" in code_block_classes:
        language = "bash"
    elif "language-c" in code_block_classes:
        language = "c"
    elif "language-cpp" in code_block_classes:
        language = "cpp"
    elif "language-cs" in code_block_classes or "language-csharp" in code_block_classes:
        language = "csharp"
    elif "language-py" in code_block_classes or "language-python" in code_block_classes:
        language = "python"
    elif "language-js" in code_block_classes or "language-javascript" in code_block_classes:
        language = "javascript"
    elif "language-json" in code_block_classes:
        language = "json"
    elif "language-yaml" in code_block_classes or "language-yml" in code_block_classes:
        language = "yaml"
    else:
        language = ""
    
    # コードブロックのマークダウン形式への変換
    if code_block:
        content_text = f'\n```{language}{filename_text}\n' + code_block.text.strip() + '\n```\n'
    else:
        content_text = ""

    return content_text

def convert_table_to_markdown(table):
    """
    テーブルの処理を行う関数
    Args:
        table (bs4.element.Tag): テーブルの要素
    Returns:
        content_text (str): マークダウン形式のテーブル
    TODO:
        テーブル内にリンク・インラインコード・画像などがある場合の処理の追加
    """
    markdown_lines = []
    headers = []
    rows = []

    # ヘッダーの取得
    thead = table.find('thead')
    if thead:
        header_row = thead.find('tr')
        headers = [th.text.strip() for th in header_row.find_all('th')]

    # ボディの取得
    tbody = table.find('tbody')
    if tbody:
        for tr in tbody.find_all('tr'):
            row = [td.text.strip() for td in tr.find_all('td')]
            rows.append(row)

    # マークダウン形式に変換
    if headers:
        markdown_lines.append('| ' + ' | '.join(headers) + ' |')
        markdown_lines.append('|' + '---|' * len(headers))
    for row in rows:
        markdown_lines.append('| ' + ' | '.join(row) + ' |')

    return '\n'.join(markdown_lines)

if __name__ == '__main__':
    from argparse import ArgumentParser
    parser = ArgumentParser(description="""
    Zennの記事を検索して、記事のタイトル、著者、URL、本文を取得してMarkdown形式で保存するスクリプト
    keywords, topics, publicationのいずれかを指定して検索""")
    parser.add_argument('-keys', '--keywords', type=str, default=None, help="キーワード検索 (複数キーワードの場合は半角スペース区切りで入力)")
    parser.add_argument('-t', '--topics', type=str, default=None, help="トピックス検索 (存在しないトピックスを指定すると何も検索できないので注意)")
    parser.add_argument('-pub', '--publication', type=str, default=None, help="団体名検索 (事前にURLを調べる必要あり。また、ページ数や並べ替えは無効なので注意)")
    parser.add_argument('-p', '--page', type=int, default=1, help="検索結果の何ページ目を参照するか指定")
    parser.add_argument('-max', '--max_search', type=int, default=5, help="検索結果の最大取得数を指定")
    parser.add_argument('--trend', action='store_true', help="トレンド順に検索")
    parser.add_argument('--latest', action='store_true', help="新規順に検索")
    parser.add_argument('--popular', action='store_true', help="人気(いいね数)順に検索")
    args = parser.parse_args()

    # キーワードのリスト化
    keywords = args.keywords
    if keywords:
        keywords = keywords.split(' ') if args.keywords else None

    # 記事の検索
    if keywords or args.topics or args.publication:
        articles = search_zenn_articles(keywords=keywords, 
                                        topics=args.topics, 
                                        publication=args.publication, 
                                        page=args.page, 
                                        trend=args.trend, 
                                        latest=args.latest, 
                                        popular=args.popular)
    else:
        publication = 'headwaters'
        articles = search_zenn_articles(publication=publication)

    # 記事の本文を取得してMarkdown形式で保存
    for i, article in enumerate(articles):
        title = article['title']
        content = get_article_content(article['link'])
        with open(f"{title}.md", "w") as f:
            f.write(f"# {article['title']}\n")
            f.write(f"- Author: [{article['user']}]({article['author_link']})\n")
            f.write(f"- Article URL: {article['link']}\n\n")
            f.write(content)
        
        if i == args.max_search:
            break

スクリプト実行例

# 以下で実行してヘッドウォータースの人気記事5件をマークダウン化できます。
python get_zenn_articles.py
# 以下のようにオプションを書くことで検索条件を変更できます
# python 正規表現で人気記事順に検索し上位10件をマークダウン化
python get_zenn_articles.py -key "python 正規表現" -max 10 --popular
# その他オプションの使い方については以下で確認
python get_zenn_articles.py --help(-h)

記事検索例

search_zenn_articlesの返り値articlesはリストで辞書型要素で記事情報を持ち、title, user, link, author_linkで記事タイトル・著者・記事リンク・著者リンクが含まれます。

記事検索例
# python 正規表現 でトレンド記事検索
articles = search_zenn_articles(keywords=["python", "正規表現"], trend=True)
# azure topicsの最新記事を検索
articles = search_zenn_articles(topics="azure", latest=True)
# ヘッドウォータースの記事を検索
articles = search_zenn_articles(publication="headwaters")

記事マークダウン化例

get_article_contentの返り値contentはマークダウン化された文字列型で記事のメタデータを含まないので、search_zenn_articlesの結果から取る必要があります。(と書いて、メタデータも別で返り値に含めればよかったと気付きました)

記事のマークダウン化
content = get_article_content("<記事リンク>")
with(open(test.md), "w") as f:
    f.write(content)

あとがき

やっていて記事をpythonでHTMLデータを処理してMarkdown化する行為(マークダウン→マークアップ→マークダウン)が〇〇らしい感じがしましたが、HTMLやMarkdownの勉強が楽しかったので良しとさせてください。Zennの記事を編集時点のMarkdownの状態でAPI取得する方法などは記事執筆時にはなかったものと認識していますが、実はあるよというのがあったらこっそり教えてください。

ヘッドウォータース

Discussion