👏

PythonでYahooニュースをWebスクレイピングする

2022/05/12に公開

更新履歴

  • 2022-10-11: ニュースのカテゴリーを選択できるようにしました。
    また、ソースコードをGithubからダウンロードできるようにしました。
  • 2022-05-16: 「はじめに」の箇所に説明文を追加しました。
  • 2022-05-13: requests.get(url)の後で1秒待機するように変更しました。

はじめに

今回が初めての投稿になります。よろしくお願い致します。
技術解説というよりは、何らかの仕事をしてくれるプログラムを紹介していきたいと思っています。

第1回目は、Pythonを使用してYahooニュースのタイトルと記事のURLをWebスクレイピングするツールを作成しようと思います。
具体的には以下のような画面の情報を取得します。

そして、プログラムの実行結果をペースターを使用してテキストエディターのキャレット位置に貼り付けます。


[P31@home "・・・"]タグの場合、

コマンドプロンプトを起動して実行する場合([P30@home "・・・"]タグ)は以下のようになります。

開発環境

今回はペースターというテキスト入力支援ツールを、Pythonプログラムを起動するためのランチャーとして使用します。ペースターは私が開発したWindows用のフリーソフトです。
公式サイトは、こちらです。
元々は「定型文の貼り付け」や「クリップボードの履歴を管理」するための常駐型のソフトですが、最近のバージョンではPythonなどのプログラムを実行する機能を積極的に追加しています。

Ver7.17(2022-05-12)からはテキストエディターなどに貼り付けるだけではなく、コマンドプロンプトを起動してプログラムを実行するオプションも追加しました。(これが一番普通なのですが・・・)

なおペースターはMacの環境では使用することができません。

現在の環境は以下のとおりです。

  • Windows10
  • Visual Studio 2019 Community
  • Python 3.9.10
  • ペースター Ver7.17

コード

ソースコードは以下のとおりです。
なお、こちらのGithubのリポジトリからダウンロードすることもできます。

yahoo_news.py
import io
import sys
import time
import unicodedata
from argparse import ArgumentParser
from urllib import robotparser
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

#sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8')
#sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

def check_robots_txt(base_url):
    robots_txt_url = f'{base_url}/robots.txt'
    targets_url = [f'{base_url}/', 
                   f'{base_url}/categories/',
                   f'{base_url}/topics/',
                   ]
    user_agent = '*'

    # robots.txtの取得
    rp = robotparser.RobotFileParser()
    rp.set_url(robots_txt_url)
    rp.read()
    time.sleep(1)

    not_list = []

    # 各URLがスクレイピング可能かチェックする
    for url in targets_url:
        res = rp.can_fetch(user_agent, f'{url}*')
        if res is False:
            print(f'can not scrape: {url}')
            not_list.append(url)

    return len(not_list) == 0


def get_target_categories(target):
    items = { 'm': '主要', 'd': '国内', 'b': '経済', 'e': 'エンタメ', 'w': '国際' }
    item_list = []

    for k in target:
        item_list.append(items[k])

    return item_list


def head_line_long(target):
    base_url = 'https://news.yahoo.co.jp/topics/'

    # 主要[m], 国内[d], 経済[b], エンタメ[e], 国際[w]
    categories = {
        '主要': 'top-picks',
        '国内': 'domestic',
        '経済': 'business',
        'エンタメ': 'entertainment',
        '国際': 'world',
        }
    
    # カテゴリーごとにループ処理する
    for cat in get_target_categories(target):
        url = urljoin(base_url, categories[cat])

        r = requests.get(url)
        time.sleep(1)

        soup = BeautifulSoup(r.content, 'lxml') # html.parser

        div_tag = soup.find('div', class_='newsFeed')
        if div_tag is None:
            print('div_tag.newsFeed is None.')
            sys.exit()

        ul_tag = div_tag.find('ul', class_='newsFeed_list')
        if ul_tag is None:
            print('ul_tag is None.')
            sys.exit()

        print(f'==={cat}===')

        for item in ul_tag.find_all('li', class_='newsFeed_item'):
            a = item.find('a')
            if a is None: continue
            topic_url = a['href']

            div_tag = a.find('div', class_='newsFeed_item_title')
            if div_tag is None:
                print('div_tag.newsFeed_item_title is None.')
                sys.exit()

            topic_headline = div_tag.text.strip()

            if topic_headline.endswith('オリジナル'):
                topic_headline = topic_headline[:-5]

            text = text_align(topic_headline, 34)
            print(f'{text}[{topic_url}]')

        print()


def head_line_short(target):
    base_url = 'https://news.yahoo.co.jp/'

    # 主要[m], 国内[d], 経済[b], エンタメ[e], 国際[w]
    categories = {
        '主要': '',
        '国内': 'categories/domestic',
        '経済': 'categories/business',
        'エンタメ': 'categories/entertainment',
        '国際': 'categories/world',
        }

    # カテゴリーごとにループ処理する
    for cat in get_target_categories(target):
        url = urljoin(base_url, categories[cat])

        r = requests.get(url)
        time.sleep(1)

        soup = BeautifulSoup(r.content, 'lxml') # html.parser

        div_tag = soup.select_one('#uamods-topics > div > div > div')
        if div_tag is None:
            print('div_tag is None.')
            break
        ul_tag = div_tag.find('ul')
        if ul_tag is None:
            print('ul_tag is None.')
            break

        print(f'==={cat}===')

        for item in ul_tag.find_all('li'):
            a = item.find('a')
            topic_url = a['href']
            topic_headline = a.text.strip()

            if topic_headline.endswith('オリジナル'):
                topic_headline = topic_headline[:-5]
            
            #print(f'{topic_headline:<18}[{topic_url}]')
            text = text_align(topic_headline, 34)
            print(f'{text}[{topic_url}]')

        print()

def get_han_count(text):
    '''
    全角文字は「2」、半角文字は「1」として文字列の長さを計算する
    '''
    count = 0

    for char in text:
        if unicodedata.east_asian_width(char) in 'FWA':
            count += 2
        else:
            count += 1

    return count

def text_align(text, width, *, align=-1, fill_char=' '):
    '''
    全角/半角が混在するテキストを
    指定の長さ(半角換算)になるように空白などで埋める
    
    width: 半角換算で文字数を指定
    align: -1 -> left, 1 -> right
    fill_char: 埋める文字を指定

    return: 空白を埋めたテキスト('abcde     ')
    '''

    fill_count = width - get_han_count(text)
    if fill_count <= 0: return text

    if align < 0:
        return text + fill_char*fill_count
    else:
        return fill_char*fill_count + text

if __name__ == '__main__':
    # ターゲットサイトの'robots.txt'をチェックする
    if not check_robots_txt('https://news.yahoo.co.jp'):
        print('Webスクレイピングが禁止されています。')
        sys.exit()

    # コマンドライン引数の処理('-f'を付けるとフル版、付けないと短縮版)
    parser = ArgumentParser()
    parser.add_argument('-f', '--full', action='store_true')
    args = parser.parse_args()

    print('取得するニュースのジャンルを指定してください。(例:me, b, ...)')
    target = input('主要[m], 国内[d], 経済[b], エンタメ[e], 国際[w]: ')
    print()

    if args.full:
        head_line_long(target)
    else:
        head_line_short(target)

解説

ソースコードについて、いくつか解説したいと思います。

robots.txtのチェック

Webスクレイピングするにあたり、対象となるサイトの「robots.txt」を調べます。
当然ですがスクレイピングがNGの場合には終了します。
上記のコードでは、check_robots_txt()という関数でチェックしています。

テキストのインデントの調整

取得したタイトルとその記事のURLを表示しますが、この時にURLの先頭位置を揃えています。これは日本語が含まれているのでちょっと面倒です。
上記のコードでは、text_align()という関数で実行しています。
また、きれいに揃えるためにはエディターなどで固定長のフォントを使用している必要があります。

Webスクレイピングの本体

実際のスクレイピングはBeautifulSoupというライブラリを使用して行います。
上記のコードでは、head_line_long()head_line_short()という関数で実行しています。
soup = BeautifulSoup(r.content, 'html.parser') # html.parser or lxml
の部分はlxmlをインストールしてあれば、そちらを使った方が高速です。

yahoo_news.pyはコマンドライン引数を取ることができます。引数を省略した場合は短縮版のニュース一覧(約10本)を取得します。-fまたは'--full'オプションを付けるとフル版のニュース一覧(20本以上)を取得します。

ペースターのVer7.17からはPythonのスクリプトにコマンドライン引数を渡すことができるようになっています。メニュー項目を右クリックで実行することにより、以下のように引数を入力するためのダイアログが表示されます。

コマンドプロンプトから普通に実行する場合には、python yahoo_news.py -fのようになります。

使い方

Visual Studio 2019やVS Codeで実行する場合には、上記のyahoo_news.pyを実行するだけです。
ただ、開発が終わって運用段階にあるプログラムを実行するために、開発環境を起動したり、コマンドプロンプトを起動して仮想環境をアクティベートして、プロジェクトフォルダに移動してpython yahoo_new.pyをタイプして・・・というのは面倒ですよね。

ペースターをランチャーとして使用してプログラムを実行する場合には、ペースターのカスタムメニューファイルに以下のように記述しておきます。あとはカスタムメニューを呼び出して(デフォルトでは[Shift]キーをダブルプッシュです)実行するだけです。

yahoo_news.py (右クリックで引数指定可能) | 
    [/][P31@home "%PERSONAL%\Visual Studio 2019\Projects\Python\General\yahoo_news.py"]
/E

上記の[P31@home ""]の'3'の部分は本家のPython環境であることを示しています。Anaconda(Miniconda)環境の場合には'4'にします。また、続く'1'の部分はプログラムが入力を受け取らずテキストを出力するだけである、ことを意味しています。ここが'2'の場合には、テキストエディターなどで現在選択されているテキストを取得して、プログラムで変更してから元の位置に貼り付けることができます。'0'の場合にはコマンドプロンプトを起動してプログラムを実行します。

最後に'@home'の部分ですが、ここには仮想環境名を指定します。今回の例ではhomeという仮想環境でyahoo_news.pyを実行する、という意味になります。'@home'の部分を省略するとグローバル環境で実行されます。

ちなみに、[P31@home "~~~*.py"]のようにワイルドカードを使用すると、メニュー項目の実行時にファイルを選択するためのダイアログが表示されます。

また、仮想環境を使用する場合にはペースターのデータフォルダにあるProgLangPathName.txtという名前のファイルに各環境用のエントリーを追加しておく必要があります。

ProgLangPathName.txt
[Python@home] C:\Users\nsnhr\PythonEnv\home\Scripts\activate.bat

このようにしてPythonのプログラムを実行すると、ペースターはバックグラウンドで以下のようなバッチファイルを作成して実行します。

temp.bat
call "C:\Users\nsnhr\PythonEnv\home\Scripts\activate.bat"

cd /d "C:\Users\nsnhr\Documents\Visual Studio 2019\Projects\Python\General"
Python.exe "C:\Users\nsnhr\Documents\Visual Studio 2019\Projects\Python\General\yahoo_news.py" < "C:\Users\nsnhr\AppData\Local\Temp\Paster\psfF6A5.tmp" > "C:\Users\nsnhr\AppData\Local\Temp\Paster\psfF6A6.tmp"

psfF6A6.tmpというファイルはテンポラリーファイルで、Pythonプログラムからprint()文などで出力されたテキストをリダイレクトして受け取ります。最終的にはこのファイルの内容をクリップボード経由でテキストエディターのキャレット位置に貼り付けます。

プログラムの実行が終わると、これらのバッチファイルやテンポラリーファイルは自動的に削除されます。
このような仕組みで動作しています。

ペースターについての補足説明

ペースターについては、初めて知った方は何が何だか分からないと思いますので、以下の公式サイトを参考にしてみてください。

ペースターの公式サイト

終わりに

次回はWindowsで勝手に溜まっていくテンポラリーファイルを削除するプログラムを紹介したいと思います。
最後まで読んで頂いてありがとうございました。

Discussion