NHK番組APIのPythonラッパーを作った

NHK番組APIのPythonラッパーを作った

どうも久しぶりです。

最近は家のインターネット環境を再整備したり、大学院入学の準備をしたり、就活を始めたり、ピアノを弾いたり、リスマネ(もとい、リスクマネジメント)の本を読んだり、女子小学生JavaScriptを始めたり、しています。

投稿が少ないのはそのためです(嘘)

まあ、ある程度の質を確保するのに、投稿が少なくなってしまうってのはある。

最近の楽しみ

そんな忙しい日の中、私の楽しみは首都圏ネットワークNHKプラスで見ることと、平日夜11時のWBSくらいしかないのです。

東京にいるときは、特に何も気にせずにニュースを見たりしていたんですけど。

NHKってニュースの時間帯、地域によって放送する番組が変わるんですよね。

今はちょっと東京から離れている地域にいて、6時半ばになると首都圏ネットワークじゃなくなくなっちゃうんですよ。

そのときに気づいたことが、「林田さん、めっちゃ可愛い!」ってことだったんですよね。

声のトーンとか、頷き方がめっちゃ好き! ピアノを弾いている姿もめっちゃカッコ良い!

これが、離れると気づく「ロス」ってやつかぁ(違う)

んで、林田さんの写真を集めたいってなって、スクレイピングできるか調べている途中で、NHK番組表APIなるものを見つけたんですよ。

これで、林田さんが出てる番組を簡単に見つけられるぞ!

なんか趣旨が変わってる気がするけど、まあ良いか。

実は画像をスクレインピングできるかどうかはまだ探しきれていない。

環境

  • requests==2.25.1
  • json==2.0.9
  • python3.8.5(さっさと更新しろ、という話)
  • Catalina(これもさっさと更新しろ、という話)

困ったこと

Pythonで組むべきか、JavaScriptで組むべきか

Pythonで組むくらいならJavaScriptで組んだ方が、webとの親和性が高いだろうし、良いんだろうと思いましたよ、確かに。

でも、私はPythonistaだし、そもそもJavaScriptはまだひよこちゃんなのでやめました。

しょ、将来的にはJavaScriptよりもPythonの方がHTMLに組み込まれてるんだからっ!(たわけ)

とりあえずJavaScriptをもっと勉強しないといけないな、となった今日この頃です。

仕様書のPDFが新しくて有料or無料で古い

何が困ったって、これを作っているときに見つけた「ジャンル」に関してなんですよね。

NHK番組表APIで公開されているジャンル早見表では全然何がなんだかわからなかったので、結局検索するしかなかったのです。

が、その結果がなかなか見つからない。

「ARIB STD-B10 デジタル放送に使用する番組配列情報標準規格 5.1版 第2部番組配列情報における基本情報のデータ構造と定義『付録H:コンテント記述子におけるジャンル指定』」とかいうやつなのですが、肝心のAPI仕様書に何も書いてなくて、電波産業会に丸投げだったんですよね。

仕方ない、PDFを探そう。

という話になったのですが、これがなかなか見つからない。

見つかったのは良いけど、有料で、どうしようかとなったときに見つけたのも英語版(しかも古い)だったという。

まあ、仕方ないから自力で組もうとなったわけです。

が、組んでから今度はGitHubで探してみたら、まああるじゃないですか

組んだ意味あるのかよ! とちょっとなりましたが、英語表記はなかったので、耐えた。

しかし、結局作ったのは古い版で、今は衛星デジタル音声放送なんて日本にはないし、そもそも4K8K放送が始まった今、もっとごちゃごちゃ追加されてるんだろうな(後で調べてみたところ、WINJ放送事故問題のあといったん消されたらしいが、その後放送大学がBSに移動したため復活した、らしい)

まあ、いざとなったらNHKに就職した友人に頼めば良い(一流企業に就職ってすごいねって思う)

この仕様書見て思ったのは、BSやらCSやらの用語があって、スガちゃんの子息と話がシンクロした(後で自分が読み直すとき用のメモだけど、菅総理の長男が衛星放送事業関連で2021年初頭に色々と利害関係者とかで騒ぎになっている

仕様書通りに戻り値を設定してほしい

仕様書が古いのか、Programで定義されている戻り値を見たときに余分にあったりするキーがあるんですよね。

いったん仕様書をちゃんと見ずに戻っている変数を見ながらプログラムを組んでいたら、エラーが出て、「なぜ?」となった。

余分なやつを追加したままプログラムを組んでいたことがわかったので、後々消しました。

というか、仕様書に”Aria”なるオブジェクトがあることになっていて「これは誤植」となったのも、色々心配になった。正しくはおそらく”Area”。

定数どこに書く?

いつも迷うんですよね。変数とか定数とかファイル生成・読込とか、どこに宣言してオブジェクトを作るのか。

確かにjsonにした方が他言語への移植とかスムーズだろうけど、「なんかやだ」ったので直接pythonに書くことにした。

単純にファイルを増やしたくない。

1人で作っているので、仕様は統一できているが、大規模なプロジェクトとかって大変だなって思いました。自分がプロジェクトチームとかに割り振られたらちゃんと要件定義の舵だけは欲しい。

久々に継承を使った

久々すぎたので、書き方を忘れてた。

検索してちゃんと組んだので、もうこれで忘れることはないでしょう。

プログラム

以下、プログラム。

こっちがmain.py。こっちがNHKAPIなるAPIラッパーの主要な部分。

import requests
from const import Area, Service, Genre, Date, Time
from datetime import datetime
import json


class Program(object):

    def __init__(self, program: dict):

        self.program    = program
        self.id         = program['id']         = int(program['id'])
        self.event_id   = program['event_id']   = int(program['event_id'])
        self.start_time = program['start_time'] = Time(program['start_time'])
        self.end_time   = program['end_time']   = Time(program['end_time'])
        self.area       = program['area']       = Area(program['area']['id'])
        self.service    = program['service']    = Service(program['service']['id'])
        self.title      = program['title']
        self.subtitle   = program['subtitle']
        self.genres     = program['genres']     = [Genre(genre) for genre in program['genres']]


    def __getattr__(self, key):

        return self.program[key]


    def __repr__(self):

        id         = self.id
        event_id   = self.event_id
        start_time = self.start_time
        end_time   = self.end_time
        area       = self.area
        service    = self.service
        title      = self.title
        subtitle   = self.subtitle
        genres     = self.genres

        return f'<{id=}, {event_id=}, {start_time=}, {end_time=}, {area=}, {service=}, {title=}, {subtitle=}, {genres=}>'



class Description(Program):

    def __init__(self, program):

        self.program = program
        super().__init__(program)

        self.content      = program['content']
        self.act          = program['act']
        self.program_logo = program['program_logo']['url']
        self.program_url  = program['program_url'] if 'program_url' in program else ''
        self.episode_url  = program['episode_url'] if 'episode_url' in program else ''
        self.hashtags     = program['hashtags']
        self.extras       = program['extras']      if 'extras'      in program else None


    def __getattr__(self, key):

        return self.program[key]


    def __repr__(self):

        id           = self.id
        event_id     = self.event_id
        start_time   = self.start_time
        end_time     = self.end_time
        area         = self.area
        service      = self.service
        title        = self.title
        subtitle     = self.subtitle
        content      = self.content
        act          = self.act
        genres       = self.genres
        program_logo = self.program_logo
        program_url  = self.program_url
        episode_url  = self.episode_url
        hashtags     = self.hashtags
        extras       = self.extras

        return f'<{id=}, {event_id=}, {start_time=}, {end_time=}, {area=}, {service=}, {title=}, {subtitle=}, {content=}, {act=}, {genres=}, {program_logo=}, {program_url=}, {episode_url=}, {hashtags=}, {extras=}>'



class NHKAPIError(Exception):

    pass



# https://api-portal.nhk.or.jp
class NHKAPI(object):

    def __init__(self, apikey: str=None):

        apikey_json_fname = 'apikey.json'

        if apikey is None:
            with open(apikey_json_fname, mode='r') as f:
                self.apikey = json.load(f)['apikey']
        else:
            self.apikey = apikey
            with open(apikey_json_fname, mode='w') as f:
                json.dump({'apikey': apikey}, f)


    def getRequests(self, url: str):

        with requests.get(url) as req:
            status = json.loads(req.text)

        if 'fault' in status:
            fault = status['fault']
            raise NHKAPIError(f'{fault["detail"]["errorcode"]}: {fault["faultstring"]}')

        return status


    def _getProgramList(self, service_li: [dict, ...], Cls: Program or Description) -> [Program, ...]:

        res = []
        for service in service_li:
            for program in service_li[service]:
                res.append(Cls(program))

        return res


    def getProgramList(self, area: int or str, service: str, date: str or datetime) -> [Program, ...]:

        area    = Area(area)
        service = Service(service)
        date    = Date(date)

        url = f'https://api.nhk.or.jp/v2/pg/list/{area}/{service}/{date}.json?key={self.apikey}'

        return self._getProgramList(self.getRequests(url)['list'], Program)


    def getProgramGenre(self, area: int, service: str, genre: str, date: str) -> [Program, ...]:

        area    = Area(area)
        service = Service(service)
        genre   = Genre(genre)
        date    = Date(date)

        url = f'https://api.nhk.or.jp/v2/pg/genre/{area}/{service}/{genre}/{date}.json?key={self.apikey}'

        dic = self.getRequests(url)
        return self._getProgramList(dic['list'], Program)


    def getProgramInfo(self, area: int, service: str, id: int) -> [Program, ...]:

        area    = Area(area)
        service = Service(service)

        url = f'https://api.nhk.or.jp/v2/pg/info/{area}/{service}/{id}.json?key={self.apikey}'

        dic = self.getRequests(url)
        return self._getProgramList(dic['list'], Description)


    def getNowOnAir(self, area: int, service: str) -> {'previous': [Program, ...], 'present': [Program, ...], 'following': [Program, ...]}:

        area    = Area(area)
        service = Service(service)

        url = f'https://api.nhk.or.jp/v2/pg/now/{area}/{service}.json?key={self.apikey}'
        dic = self.getRequests(url)['nowonair_list']

        print(dic)

        res = {timing: [] for timing in ['previous', 'present', 'following']}
        for service in dic:
            for timing in dic[service]:
                res[timing].append(Program(dic[service][timing]))

        return res



if __name__ == '__main__':

    api = NHKAPI()

    #area, service, genre, date = 130, 'tv', 9, '2021-02-25'
    area, service, genre, date = 130, 'g1', 9, '2021-02-25'
    apikey  = '1AnV************************2B5c'
    programList = api.getProgramList(area, service, date)[12::20]
    programList = api.getProgramGenre(area, service, genre, date)[5::10]

    program = api.getProgramGenre(area, service, genre, date)[5]
    print(api.getProgramInfo(area, service, program.id))

    print(api.getNowOnAir(area, service))

で、これが、const.py。定数を宣言している。

from datetime import datetime, timezone, timedelta
import re


__area_dic__ = {
    # 北海道
    '010': ('札幌', 'sapporo'),
    '011': ('函館', 'hakodate'),
    '012': ('旭川', 'asahikawa'),
    '013': ('帯広', 'obihiro'),
    '014': ('釧路', 'kushiro'),
    '015': ('北見', 'kitami'),
    '016': ('室蘭', 'muroran'),

    # 東北
    '020': ('青森', 'aomori'),
    '030': ('盛岡', 'morioka'),
    '040': ('仙台', 'sendai'),
    '050': ('秋田', 'akita'),
    '060': ('山形', 'yamagata'),
    '070': ('福島', 'fukushima'),

    # 関東
    '080': ('水戸', 'mito'),
    '090': ('宇都宮', 'utsunomiya'),
    '100': ('前橋', 'maebashi'),
    '110': ('さいたま', 'saitama'),
    '120': ('千葉', 'chiba'),
    '130': ('東京', 'tokyo'),
    '140': ('横浜', 'yokohama'),

    # 中部
    '150': ('新潟', 'niigata'),
    '160': ('富山', 'toyama'),
    '170': ('金沢', 'kanazawa'),
    '180': ('福井', 'fukui'),
    '190': ('甲府', 'kofu'),
    '200': ('長野', 'nagano'),
    '210': ('岐阜', 'gifu'),
    '220': ('静岡', 'shizuoka'),
    '230': ('名古屋', 'nagoya'),
    '240': ('津', 'tsu'),

    # 近畿
    '250': ('大津', 'otsu'),
    '260': ('京都', 'kyoto'),
    '270': ('大阪', 'osaka'),
    '280': ('神戸', 'kobe'),
    '290': ('奈良', 'nara'),
    '300': ('和歌山', 'wakayama'),

    # 中国
    '310': ('鳥取', 'tottori'),
    '320': ('松江', 'matsue'),
    '330': ('岡山', 'okayama'),
    '340': ('広島', 'hiroshima'),
    '350': ('山口', 'yamaguchi'),

    # 四国
    '360': ('徳島', 'tokushima'),
    '370': ('高松', 'takamatsu'),
    '380': ('松山', 'matsuyama'),
    '390': ('高知', 'kochi'),

    # 九州
    '400': ('福岡', 'fukuoka'),
    '401': ('北九州', 'kitakyushu'),
    '410': ('佐賀', 'saga'),
    '420': ('長崎', 'nagasaki'),
    '430': ('熊本', 'kumamoto'),
    '440': ('大分', 'oita'),
    '450': ('宮崎', 'miyazaki'),
    '460': ('鹿児島', 'kagoshima'),
    '470': ('沖縄', 'okinawa')
}

class Area(object):

    def __init__(self, key: int or str):

        self.id, self.name, self.name_en = self.get_id_name(key)

        self.area = {
            'id'     : self.id,
            'name'   : self.name,
            'name_en': self.name_en,
        }

    
    def get_id_name(self, key: int or str):

        if type(key) is int or re.match('[0-9]+', key) is not None:
            return int(key), __area_dic__[str(key)][0], __area_dic__[str(key)][1]

        elif type(key) is str:
            key = key.lower()
            for kint, area in __area_dic__.items():
                if key in area:
                    return int(kint), area[0], area[1]
            else :
                raise ValueError(f'There is no local broadcast named {key.capitalize()} in NHK.<{key=}>')
        else:
            raise ValueError(f'area key is illegal.<{key=}>')


    def help(self):

        help_li = [
            'Choose an area in the following table:',
            '=' * 16
        ] + [
            f'  {key}: {__area_dic__[key][0]}(__area_dic__[key][1])' for key in __area_dic__
        ]

        return '\n'.join(help_li)


    def __getattr__(self, key):

        return self.area[str(self.get_id_name(key)[0])]


    def __str__(self):

        return f'{self.id:03}'


    def __repr__(self):

        id, name, name_en = self.id, self.name, self.name_en

        return f'<{id=:03}, {name=}, {name_en=}>'



__service_dic__ = {
    'g1': 'NHK総合1',
    'g2': 'NHK総合2',
    'e1': 'NHKEテレ1',
    'e2': 'NHKEテレ2',
    'e3': 'NHKEテレ3',
    'e4': 'NHKワンセグ2',
    's1': 'NHKBS1',
    's2': 'NHKBS1(102ch)',
    's3': 'NHKBSプレミアム',
    's4': 'NHKBSプレミアム(104ch)',
    'r1': 'NHKラジオ第1',
    'r2': 'NHKラジオ第2',
    'r3': 'NHKFM',
    'n1': 'NHKネットラジオ第1',
    'n2': 'NHKネットラジオ第2',
    'n3': 'NHKネットラジオFM',

    'tv'      : 'テレビ全て',
    'radio'   : 'ラジオ全て',
    'netradio': 'ネットラジオ全て'
}

class Service(object):

    def __init__(self, key: str):

        if key in __service_dic__:
            self.id   = key
            self.name = __service_dic__[key]

        else:
            raise ValueError(f'service key is illegal.<{key=}>')

        self.service = {
            'id'  : self.id,
            'name': self.name
        }


    def help(self):

        help_li = [
            'Choose a service in the following table:',
            '=' * 16
        ] + [
            f'  {key}: {__service_dic__[key]}' for key in __service_dic__
        ]

        return '\n'.join(help_li)


    def __getattr__(self, key):

        return self.service[key]


    def __str__(self):

        return str(self.id)


    def __repr__(self):

        id, name = self.id, self.name

        return f'<{id=}, {name=}>'


# https://www.arib.or.jp/english/html/overview/doc/6-STD-B10v4_6-E2.pdf
__large_genre_classification__ = [
    ('ニュース/報道', 'News, report'),
    ('スポーツ', 'Sports'),
    ('情報/ワイドショー', 'Information/tabloid show'),
    ('ドラマ', 'Drama'),
    ('音楽', 'Music'),
    ('バラエティ', 'Variety show'),
    ('映画', 'Movies'),
    ('アニメ/特撮', 'Animation/special effect movies'),
    ('ドキュメンタリー/教養', 'Documentary/culture'),
    ('劇場/公演', 'Theatre/public performance'),
    ('趣味/教育', 'Hobby/education'),
    ('福祉', 'Welfare'),
    ('予備', 'Reserved'),
    ('予備', 'Reserved'),
    ('拡張', 'For extension'),
    ('その他', 'Others')
]

__middle_genre_classification__ = [
    # ニュース/報道
    ('定時・総合', 'Regular, general'),
    ('天気', 'Weather report'),
    ('特集・ドキュメント', 'Special program, documentary'),
    ('政治・国会', 'Politics, national assembly'),
    ('経済・市況', 'Economics, market report'),
    ('海外・国際', 'Overseas, international report'),
    ('解説', 'News analysis'),
    ('討論・会談', 'Discussion, conference'),
    ('報道特番', 'Special report'),
    ('ローカル・地域', 'Local program'),
    ('交通', 'Traffic report'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # スポーツ
    ('スポーツニュース', 'Sports news'),
    ('野球', 'Baseball'),
    ('サッカー', 'Soccer'),
    ('ゴルフ', 'Golf'),
    ('その他の球技', 'Other ball games'),
    ('相撲・格闘技', 'Sumo, combative sports'),
    ('オリンピック・国際大会', 'Olympic, international games'),
    ('マラソン・陸上・水泳', 'Marathon, athletic sports, swimming'),
    ('モータースポーツ', 'Motor sports'),
    ('マリン・ウィンタースポーツ', 'Marine sports, winter sports'),
    ('競馬・公営競技', 'Horse race, public race'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 情報/ワイドショー
    ('芸能・ワイドショー', 'Gossip/tabloid show'),
    ('ファッション', 'Fashion'),
    ('暮らし・住まい', 'Living, home'),
    ('健康・医療', 'Health, medical treatment'),
    ('ショッピング・通販', 'Shopping, mail-order business'),
    ('グルメ・料理', 'Gourmet, cocking'),
    ('イベント', 'Events'),
    ('番組紹介・お知らせ', 'Program guide, information'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # ドラマ
    ('国内ドラマ', 'Japanese dramas'),
    ('海外ドラマ', 'Overseas dramas'),
    ('時代劇', 'Period dramas'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 音楽
    ('国内ロック・ポップス', 'Japanese rock, pop music'),
    ('海外ロック・ポップス', 'Overseas rock, pop music'),
    ('クラシック・オペラ', 'Classic, opera'),
    ('ジャズ・フュージョン', 'Jazz, fusion'),
    ('歌謡曲・演歌', 'Popular songs, Japanese popular songs (enka songs)'),
    ('ライブ・コンサート', 'Live concert'),
    ('ランキング・リクエスト', 'Ranking, request music'),
    ('カラオケ・のど自慢', 'Karaoke, amateur singing contests'),
    ('民謡・邦楽', 'Japanese ballad, Japanese traditional music'),
    ('童謡・キッズ', 'Children\'s song'),
    ('民族音楽・ワールドミュージック', 'Folk music, world music'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # バラエティ
    ('クイズ', 'Quiz'),
    ('ゲーム', 'Game'),
    ('トークバラエティ', 'Talk variety'),
    ('お笑い・コメディ', 'Comedy program'),
    ('音楽バラエティ', 'Music variety'),
    ('旅バラエティ', 'Tour variety'),
    ('料理バラエティ', 'Cocking variety'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 映画
    ('洋画', 'Overseas movies'),
    ('邦画', 'Japanese movies'),
    ('アニメ', 'Animation'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # アニメ/特撮
    ('国内アニメ', 'Japanese animation'),
    ('海外アニメ', 'Overseas animation'),
    ('特撮', 'Special effects'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # ドキュメンタリー/教養
    ('社会・時事', 'Social, current events'),
    ('歴史・紀行', 'History, travel record'),
    ('自然・動物・環境', 'Nature, animal, environment'),
    ('宇宙・科学・医学', 'Space, science, medical science'),
    ('カルチャー・伝統文化', 'Culture, traditional culture'),
    ('文学・文芸', 'Literature, literary art'),
    ('スポーツ', 'Sports'),
    ('ドキュメンタリー全般', 'Total documentary'),
    ('インタビュー・討論', 'Interviews, discussions'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 劇場/公演
    ('現代劇・新劇', 'Modern drama, Western-style drama'),
    ('ミュージカル', 'Musical'),
    ('ダンス・バレエ', 'Dance, ballet'),
    ('落語・演芸', 'Comic story, entertainment'),
    ('歌舞伎・古典', 'Kabuki, classical drama'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 趣味/教育
    ('旅・釣り・アウトドア', 'Trip, fishing, outdoor entertainment'),
    ('園芸・ペット・手芸', 'Gardening, pet, handicrafts'),
    ('音楽・美術・工芸', 'Music, art, industrial art'),
    ('囲碁・将棋', 'Japanese chess (shogi) and "go"'),
    ('麻雀・パチンコ', 'Mah-jong, pinball games'),
    ('車・オートバイ', 'Cars, motorbikes'),
    ('コンピュータ・TVゲーム', 'Computer, TV games'),
    ('会話・語学', 'Conversation, languages'),
    ('幼児・小学生', 'Little children, schoolchildren'),
    ('中学生・高校生', 'Junior high school and high school students'),
    ('大学生・受験', 'University students, examinations'),
    ('生涯教育・資格', 'Lifelong education, qualifications'),
    ('教育問題', 'Educational problem'),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 福祉
    ('高齢者', 'Old aged persons'),
    ('障害者', 'Social welfare'),
    ('社会福祉', 'Handicapped persons'),
    ('ボランティア', 'Volunteers'),
    ('手話', 'Sign language'),
    ('文字(字幕)', 'Text (subtitles)'),
    ('音声解説', 'Explanation on sound multiplex broadcast'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # 予備
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),

    # 予備
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),

    # 拡張
    ('BS/地上デジタル放送用番組付属情報', 'Appendix information for BS/terrestrial digital broadcast program'),
    ('広帯域CSデジタル放送用拡張', 'Extension for broadband CS digital broadcasting'),
    ('衛星デジタル音声放送用拡張', 'Extension for digital satellite sound broadcasting'),
    ('サーバー型番組付属情報', 'Appendix information for server-type program'),
    ('IP 放送用番組付属情報', 'Appendix information for IP broadcast program'),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others'),

    # その他
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('', ''),
    ('その他', 'Others')
]

class Genre(object):

    def __init__(self, key: int or str):

        if type(key) is int or re.match('^[0-9]+$', key) is not None:
            self.id      = int(key)

        elif type(key) is str and re.match('^0x[0-9a-fA-F]+$', key) is not None:
            self.id      = int(key[-2], 16) * 100 + int(key[-1], 16)

        else:
            raise ValueError(f'genre key is illegal.<{key=}>')

        level1, level2 = divmod(self.id, 100)

        self.described_content    = __large_genre_classification__ [level1][0]
        self.described_content_en = __large_genre_classification__ [level1][1]
        self.description          = __middle_genre_classification__[level1*16 + level2][0]
        self.description_en       = __middle_genre_classification__[level1*16 + level2][1]

        self.genre = {
            'id'                  : self.id,
            'described_content'   : self.described_content,
            'described_content_en': self.described_content_en,
            'description'         : self.description,
            'description_en'      : self.description_en
        }


    def help(self):

        help_li = [
            'Choose a genre in the following table',
            '(int or str in 10-decimals. 16-decimals is only str type):',
            '=' * 16
        ]

        for level1 in range(16):
            described_content, described_content_en = __large_genre_classification__[level1]
            help_li.append(f'  {level1:02}**({hex(level1).upper()}*): {described_content}(described_content_en)')
            for level2 in range(16):
                description, description_en = __middle_genre_classification__[level1*16 + level2]
                help_li.append(f'  {level1:02}{level1:02}({hex(level1*16 + level2).upper()}*): {described_content}(described_content_en)')

        return '\n'.join(help_li)


    def __getattr__(self, key):

        return self.service[key]


    def __str__(self):

        return f'{self.id:04}'


    def __repr__(self):

        id                   = self.id
        described_content    = self.described_content
        described_content_en = self.described_content_en
        description          = self.description
        description_en       = self.description_en

        return f'<{id=:04}, {described_content=}, {described_content_en=}, {description=}, {description_en=}>'



class Date(object):

    def __init__(self, date: str or datetime):

        if type(date) is datetime:
            self.date = date.strftime('%Y-%m-%d')
        elif type(date) is str and re.match('[0-9]{4}-[0-9]{2}-[0-9]{2}', date) is not None:
            self.date = date
        else:
            raise AttributeError(f'date must be class datetime or format "yyyy-mm-dd".<{date}>')


    def __str__(self):

        return str(self.date)


    def __repr__(self):

        date = self.date

        return f'<{date=}>'



class Time(object):

    def __init__(self, time: str):

        self.time = datetime.strptime(time, '%Y-%m-%dT%H:%M:%S%z')


    def __str__(self):

        return str(self.time)


    def __repr__(self):

        time = self.time.strftime('%Y-%m-%dT%H:%M:%S%z')

        return f'<{time=}>'

相変わらずコメント少ないソースコードだけど今回は分かりやすい変数名にしたから、まあ平気っしょ()

で本題の、林田さんが出ている番組を1週間にわたって探してみました。

from main import NHKAPI
from time import sleep


if __name__ == '__main__':

    api = NHKAPI()

    area, service = 'tokyo', 'g1'
    res = []
    for day in range(21, 28):
        res += [
            program
            for program in api.getProgramList(area, service, f'2021-02-{day}')
            if '林田理沙' in program.act
        ]
        sleep(1)

    print('\n'.join(map(repr, res)))
<id=2021022206337, event_id=6337, start_time=<time='2021-02-22T18:10:00+0900'>, end_time=<time='2021-02-22T18:30:00+0900'>, area=<id=130, name='東京', name_en='tokyo'>, service=<id='g1', name='NHK総合1'>, title='首都圏ネットワーク\u3000コロナ死別遺族の思い\u3000大型車両も自動で運転', subtitle='現場取材で鍛えた視点をもつキャスター高井正智と「ブラタモリ」で街を見続けた林田理沙が、首都圏の生活にかかわる大事なニュースと気象情報をいち早く、より深く伝えます', genres=[<id=0009, described_content='ニュース/報道', described_content_en='News, report', description='ローカル・地域', description_en='Local program'>, <id=0002, described_content='ニュース/報道', described_content_en='News, report', description='特集・ドキュメント', description_en='Special program, documentary'>, <id=0202, described_content='情報/ワイドショー', described_content_en='Information/tabloid show', description='暮らし・住まい', description_en='Living, home'>]>
<id=2021022206338, event_id=6338, start_time=<time='2021-02-22T18:30:00+0900'>, end_time=<time='2021-02-22T18:52:00+0900'>, area=<id=130, name='東京', name_en='tokyo'>, service=<id='g1', name='NHK総合1'>, title='首都圏ネットワーク(東京・神奈川・千葉・埼玉)\u3000各県情報をたっぷり', subtitle='あなたの近くにある「ふるさと」千葉・神奈川・埼玉・東京を「ちかさと」と呼び、1都3県の地域密着の生活に役立つニュースと気象情報を、わかりやすく伝えます。', genres=[<id=0009, described_content='ニュース/報道', described_content_en='News, report', description='ローカル・地域', description_en='Local program'>, <id=0002, described_content='ニュース/報道', described_content_en='News, report', description='特集・ドキュメント', description_en='Special program, documentary'>, <id=0202, described_content='情報/ワイドショー', described_content_en='Information/tabloid show', description='暮らし・住まい', description_en='Living, home'>]>
...

ちゃんと取れてる🥳

おわりに

番組表とかレコーダーの仕組みがなんとなくわかったような気がしました。

最近は見たい芸能人が出てる番組を自動で録画してくれるってのもあるらしくて、「AIで録画で録画できるんです!」とか言って、録画機能に「松本潤」を登録したら政治家がヒットして録画されてたとかというのは、だいたいこういう技術を使っているのだろう(形態素解析は人工知能ではないような気がするけど)

まあ、何がいちばん言いたいのかっていうと、林田さん、可愛すぎだろ。