Pocketに溜まった記事をNotionに移行したい!移行ツールをPythonで自作

「後で読む」と思ってPocketに保存した記事が、気がつけば数百件と溜まっていませんか?私もその一人です😫興味深い記事を見つけるたびにPocketに保存するものの、実際に読み返すことは少なく、積読状態😑

しかし、Pocketに大きな変化が起きました。MozillaFirefox の開発元)によるPocketの終了が2025年7月と発表されました。これにより、多くのユーザーが代替サービスへの移行を余儀なくされます。

Pocketは記事を保存するには便利なツールですが、記事を整理・活用するとなると少し物足りない部分もありました。

  • タイトルやタグでの検索はできるが、内容からの検索が難しい
  • メモやプロジェクトとの関連付けができず、さらに別の場所に格納する必要がある
  • カテゴリ分けが思うようにいかない

移行後にはこれらの問題を解決できるものにしたいと思い、個人的にはNotionを採用することを決めました。

Notionについて

Notionは情報管理がかなり優秀だと感じています。その理由は以下でしょうか。

データベース機能による構造化

Notionのデータベース機能を使えば、URLやタイトルだけでなく、評価、メモなど、いろいろな情報を加えて管理ができます。

検索・フィルタリング機能のアップ

データベース化することで、複数の条件を組み合わせたフィルタリングが検索ができるようになります。

他の情報との連携

データベースの情報を互いに関連付けることができます。個人的にはこれが一番やりたいことになります。

移行は、登録している件数が非常に多いため、Pythonでツールを作ることにします。すでに移行ツールはありそうではありますが、車輪の再開発大好き🤩なので自作にチャレンジとなります。

Pocketエクスポート形式の変更

ツール作成を始めて最初に気づいたのが、Pocketのエクスポート形式が2024年あたりに大きく変わったことでした。 以前はHTMLファイル形式だったのが、現在はCSV形式(ZIP圧縮)に変更されています。

  • ファイル形式がCSV(カンマ区切り値)
  • エクスポートは管理ページからリクエス
  • エクスポートデータはメールで送られたリンクからダウンロード(Pocket.zipという名前になります)
  • 10,000件ずつ複数のCSVファイルに分割

以前からなぜHTMLなのかな🤔と思っていた節もあったので、今回の変更はどちらかといえば嬉しく思えます。

【参考】以前のエクスポート情報 古いので使用しないように!

note.com

移行ツールの概要

PocketCSVエクスポートデータからNotionデータベースに記事情報を一括移行するPythonツールとなります。

以降では、PocketCSVデータ構造、Notion APIの設定について準備していきます。

Pocketデータの構造

Pocketのエクスポートデータがどのような構造になっているかを理解していきます。

最新のエクスポート方法

【参考】

note.com

Pocketからのデータエクスポート手順は以下のようになります。

1)Pocketアカウントでログインした状態で、getpocket.com/export にアクセスし、【CSVファイルをエクスポート】をクリック

2)エクスポート要求の確認メッセージが表示、同時に要求確認のメールが届きます。

3)最大24時間以内(通常は数分)でメールが届くので、メール内のリンクからpocket.zipファイルをダウンロード

注意点

  • 大量のデータがある場合は処理に時間がかかる
  • ダウンロードリンクは3日で有効期限切れ
  • 10,000件を超える場合、複数のCSVファイルに分割される

ZIPファイルの構造

ダウンロードしたpocket.zipは以下の構造になっています。

pocket.zip
├── pocket_data_1.csv    # 1〜10,000件目
├── pocket_data_2.csv    # 10,001〜20,000件目(データが多い場合)
└── ...                  # 必要に応じて追加のCSVファイル

大量のデータがある場合、自動的に10,000件ずつ複数のCSVファイルに分割されます。

CSVファイルの構造詳細

CSVファイルの構造は以下のようになっています:

CSVデータの例

title,url,time_added,tags,status
とほほのWWW入門,https://www.tohoho-web.com/www.htm,1652050889,,unread
国土数値情報ダウンロードサービス,http://nlftp.mlit.go.jp/ksj/index.html,1523686303,map,unread
https://streamyard.com/,https://streamyard.com/,1618294655,,unread
…以下略…

カラム(列)の詳細説明

カラム名 データ型 説明
title 文字列 記事のタイトル(空の場合はURLと同じ値) とほほのWWW入門
url 文字列 記事のURL(必須) https://www.tohoho-web.com/www.htm
time_added 数値 追加日時のUnix timestamp 1652050889
tags 文字列 タグ(単一、空の場合が多い) map
status 文字列 記事のステータス unread / archive

データの注意点

  • タイトル … 一部の記事ではtitleがURLと同じ値になっている(Pocketが記事のタイトルを取得できなかった場合)
  • タイムスタンプ … Unix timestamp形式(1970年1月1日からの秒数)
  • タグ … 多くの記事でタグが空(空文字列)の事が多い

Notionの準備

移行先となるNotion側の準備を行います。

基本的には以前書いた以下の記事とほぼ同じ操作を行うことになるので、概要だけの説明にします。

参考

uepon.hatenadiary.com

Integrationの作成(Notion APIを使用時に必要)

1. Notionにログインした状態で、Notionクリエータープロフィール/インテグレーション にアクセスして、【新しいインテグレーション】ボタンをクリック

2. インテグレーションの名を入力(今回はPocket CSV Importerとする)、関連ワークスペースを選択、【保存】ボタンをクリック

3. 表示された内部インテグレーションシークレット(以降トークン)をコピーして保存しておく。

このトークンは後ほどプログラムで使用します。絶対に外部に漏らさないよう注意してください。

格納するデータベースを事前に作成する

Pocketデータに対応したNotionデータベースを作成します。

今回は以下のような構造にしていますが、必要な情報があれば適宜追加してください。データベースの名前はPocketにしました。

プロパティ名 タイプ 必須 説明
Title Title(デフォルト値) 記事のタイトル
URL URL 記事のURL
Domain テキスト ウェブサイトのドメイン名(自動抽出)
Source 選択 データソース(「Pocket」を選択肢として作成)
Status 選択 元のPocketでのステータス(Unread/Archive)
AddedDate Date Pocketに追加された日時
Tags マルチセレクト 記事のタグ
ReadingStatus 選択 新しい読了状況(未読、読了、保留など)
Rating 選択 記事の評価(⭐-⭐⭐⭐⭐⭐)

選択のプロパティの設定

以下の選択マルチセレクトのタイプはオプション(プロパティ)は、事前に選択項目を設定します。 プロパティの【オプション+】ボタンをクリックすると追加できます。

Sourceオプション(プロパティ) - Pocket(必須)

Statusオプション(プロパティ) - Unread - Archive

ReadingStatusオプション(プロパティ)

  • 未読
  • 読了
  • 保留
  • 要再読

Ratingオプション(プロパティ)

  • なし
  • ⭐⭐⭐⭐⭐
  • ⭐⭐⭐⭐
  • ⭐⭐⭐
  • ⭐⭐

データベースの共有設定

作成したデータベースをIntegrationに接続します。

1. データベースページの右上【…】をクリック

2. 【接続】をクリック

3. 作成したインテグレーションPocket CSV Importerを検索して選択

4. ダイアログで接続する【はい】ボタンをクリック

データベースIDの取得

プログラムで使用するデータベースIDを取得します。データベースIDはデータベースのURLに含まれています。 URLは以下のような形式になっています。

https://www.notion.so/[ドメイン名(省略可)]/[DATABASE ID(32桁の文字列)]?v=[VIEW ID]

具体的には、以下のようなURLとなっています。

https://www.notion.so/1f602b8bc47b8097a475e1a362a1da8d?v=1f602b8bc47b809e87c2000cc1081d49

このうちドメインの後にある”/” と ”?” で囲まれた部分の32桁の部分がデータベースIDとなります。この値をコピーして控えておきます。この例であれば1f602b8bc47b8097a475e1a362a1da8dとなります。

(注意点)APIの制限事項と対策

Notion APIには以下のレート制限があります。

  • 同時リクエスト制限: 1つのIntegrationあたり平均で毎秒3リクエストまで(短期的なバーストは許容)。HTTP接続の同時数に明確な制限はないが、レート制限に達すると429 Too Many Requestsが返る。

大量のCSVデータを移行する際は、上記の制限を考慮して処理間隔を調整できるようにしています。 今回は0.3秒の待機時間を設けています。

処理の全体像

ここまでの準備ができたので、以下のような流れでデータ移行を行います。 ソースコードの全体はこのページの末尾にあります。

  1. Pocketからエクスポート: CSV形式のZIPファイルをメールで受信
  2. ZIPファイル展開: 複数のCSVファイルを抽出
  3. CSVファイル解析: pandasでデータ読み込み・クリーニング
  4. データ変換: PocketのCSVデータをNotion形式に変換
  5. API呼び出し: Notion APIでデータベースに順次追加
  6. 結果確認: 移行成功・失敗の集計とレポート

ツールの実装

ライブラリ選定の変更

  • pandas … CSVデータの処理
  • notion-client … Notion API操作
  • zipfile … ZIPファイルの展開(標準ライブラリのためインストールは不要)
  • python-dotenv … APIキーなどの設定管理

ZIPファイル展開処理

Pocketのエクスポートデータは、ZIPファイル内に複数のCSVが含むため、ツールにも展開処理を含めています。

def extract_csv_from_zip(self, zip_file_path: str) -> List[str]:
    """
    ZIPファイルからCSVファイルを抽出する
    
    Args:
        zip_file_path (str): PocketエクスポートZIPファイルのパス
        
    Returns:
        List[str]: 抽出されたCSVファイルのパスリスト
        
    Raises:
        FileNotFoundError: ZIPファイルが存在しない場合
        zipfile.BadZipFile: 無効なZIPファイルの場合
    """
    # ... 省略 ...
    except FileNotFoundError:
        logger.error(f"ZIPファイルが見つかりません: {zip_file_path}")
        raise
    except zipfile.BadZipFile:
        logger.error(f"無効なZIPファイルです: {zip_file_path}")
        raise
    except Exception as e:
        logger.error(f"ZIPファイルの展開中にエラーが発生しました: {str(e)}")
        raise

pandas活用したCSV解析処理

PocketのCSVファイルから記事情報を抽出しています。

def parse_pocket_csv(self, csv_file_path: str) -> List[Dict[str, Any]]:
    """PocketのCSVファイルを解析して記事情報を抽出"""
    
    try:
        # 複数エンコーディングに対応したCSV読み込み
        try:
            df = pd.read_csv(csv_file_path, encoding='utf-8')
        except UnicodeDecodeError:
            try:
                df = pd.read_csv(csv_file_path, encoding='cp1252')
            except UnicodeDecodeError:
                df = pd.read_csv(csv_file_path, encoding='shift_jis')
        
        articles: List[Dict[str, Any]] = []
        
        # 各行を処理
        for _, row in df.iterrows():
            # タイトルの処理(空の場合はURLを使用)
            title = row.get('title', '')
            if pd.isna(title) or title == '':
                title = row.get('url', '')
            
            # URLチェック(必須項目)
            url = row.get('url', '')
            if pd.isna(url):
                continue  # URLが無い行はスキップ
            
            article: Dict[str, Any] = {
                'title': str(title),
                'url': str(url),
                'tags': [],
                'added_date': None,
                'time_added': None,
                'status': str(row.get('status', 'unread'))
            }
            
            # タイムスタンプ処理
            time_added = row.get('time_added')
            if not pd.isna(time_added):
                try:
                    timestamp = int(float(time_added))
                    article['added_date'] = datetime.fromtimestamp(timestamp)
                    article['time_added'] = str(timestamp)
                except (ValueError, TypeError, OSError) as e:
                    logger.warning(f"タイムスタンプの変換に失敗: {time_added}")
            
            # タグを処理
            tags = row.get('tags')
            if not pd.isna(tags) and tags != '':
                # タグは単一の文字列として格納されている場合が多い
                tag_str = str(tags).strip()
                if tag_str:
                    # カンマ区切りの場合とスペース区切りの場合に対応
                    if ',' in tag_str:
                        article['tags'] = [tag.strip() for tag in tag_str.split(',') if tag.strip()]
                    else:
                        article['tags'] = [tag_str]
            
            articles.append(article)
        
        return articles
        
    except Exception as e:
        logger.error(f"CSVファイルの解析中にエラー: {str(e)}")
        raise

Notion API呼び出し処理

抽出したCSVデータをNotionデータベースに送信する処理になります。

以前のエントリの拡張版となります。基本は以下を参照してください。

uepon.hatenadiary.com

def create_notion_page(self, article: Dict[str, Any]) -> bool:
    """Notionデータベースに記事ページを作成"""
    
    try:
        # URLからドメインを自動抽出
        domain = ''
        try:
            from urllib.parse import urlparse
            parsed_url = urlparse(article['url'])
            domain = parsed_url.netloc
        except Exception:
            domain = ''
        
        # 必須プロパティを構築
        properties: Dict[str, Any] = {
            "Title": {
                "title": [{"text": {"content": article['title'][:100]}}]
            },
            "URL": {
                "url": article['url']
            },
            "Domain": {
                "rich_text": [{"text": {"content": domain}}]
            },
            "Source": {
                "select": {"name": "Pocket"}
            }
        }
        
        # オプションプロパティを存在確認してから追加
        
        # Status プロパティ(Pocketでの元ステータス)
        if "Status" in self.available_properties:
            properties["Status"] = {
                "select": {"name": article.get('status', 'unread').capitalize()}
            }
        
        # ReadingStatus プロパティ(Notionでの読了管理、デフォルトは「未読」)
        if "ReadingStatus" in self.available_properties:
            properties["ReadingStatus"] = {
                "select": {"name": "未読"}
            }
        
        # 追加日時があり、プロパティが存在する場合のみ設定
        if article.get('added_date') and "AddedDate" in self.available_properties:
            properties["AddedDate"] = {
                "date": {"start": article['added_date'].isoformat()}
            }
        
        # タグがあり、プロパティが存在する場合のみ設定
        if article.get('tags') and "Tags" in self.available_properties:
            properties["Tags"] = {
                "multi_select": [
                    {"name": tag[:100]} for tag in article['tags'][:10]
                ]
            }
        
        # ページ作成
        response = self.notion.pages.create(
            parent={"database_id": self.database_id},
            properties=properties
        )
        
        self.imported_count += 1
        return True
        
    except APIResponseError as e:
        logger.error(f"Notion API エラー: {str(e)}")
        self.error_count += 1
        return False

APIキーなどの設定と管理

APIキーやデータベースIDといった情報に関しては.envに格納して使用しています。

def main() -> None:
    """
    メイン実行関数
    
    環境変数から設定を読み込み、PocketからNotionへのインポートを実行する
    必要な環境変数が設定されていない場合は、エラーメッセージを表示して終了する
    """
    # 設定を.envファイルから読み込み
    notion_token: Optional[str] = os.getenv('NOTION_TOKEN')
    database_id: Optional[str] = os.getenv('NOTION_DATABASE_ID')
    file_path: str = os.getenv('POCKET_FILE', 'pocket.zip')
    
    # 設定の確認
    if not notion_token:
        print("エラー: NOTION_TOKENが設定されていません")
        print(".envファイルにNotion Integration Tokenを設定してください")
        print("例: NOTION_TOKEN=secret_your_token_here")
        return
    
    if not database_id:
        print("エラー: NOTION_DATABASE_IDが設定されていません")
        print(".envファイルにNotionデータベースIDを設定してください")
        print("例: NOTION_DATABASE_ID=your_database_id_here")
        return
    
    if not os.path.exists(file_path):
        print(f"エラー: ファイルが見つかりません: {file_path}")
        print("Pocketからエクスポートしたファイル(pocket.zipまたは.csv)を同じディレクトリに配置してください")
        print("または.envファイルでファイルパスを設定してください: POCKET_FILE=path/to/your/file")
        return

.envファイルの例

各値は以下と入れ替えてください。

  • secret_your_integration_token … Integrationのトーク
  • your_database_id … データベースID
# Notion設定
NOTION_TOKEN=secret_your_integration_token_here
NOTION_DATABASE_ID=your_database_id_here

# ファイル設定(CSVまたはZIP)
POCKET_FILE=pocket.zip

# API制御設定
API_DELAY=0.3

実際に使ってみる

セットアップ手順

1. 必要なファイルの準備

まず、プロジェクト用のディレクトリを作成し、必要なファイルを配置します:

pocket-to-notion/
├── pocket_to_notion.py      # メインプログラム(CSV対応版)
├── requirements.txt         # 依存ライブラリ(pandas追加)
├── .env.example            # 設定テンプレート
├── .env                    # 実際の設定(後で作成)
└── pocket.zip              # Pocketエクスポートファイル(ZIPまたはCSV)

2. Python環境の準備**

仮想環境を作成して、ライブラリのインストールします。

# 仮想環境の作成
$ python -m venv venv

# 仮想環境の有効化
$ source venv/bin/activate

# 依存ライブラリのインストール
$ pip install -r requirements.txt

requirements.txtの内容

notion-client>=2.0.0
pandas>=1.5.0
requests>=2.28.0
python-dotenv>=1.0.0

3. 環境変数などの設定

設定ファイルの作成 env.example.envにコピーして、実際の設定値を入力します。

リポジトリのファイル名の先頭にはドットがありませんが、実際に使用するファイルは先頭にドットがありますので注意してください。

$ cp env.example .env

4. データの準備確認

  • Pocketからエクスポートしたpocket.zipがプロジェクトディレクトリにあることを確認
  • NotionのIntegrationトークンとデータベースIDが正しく設定されていることを確認
  • Notionデータベースが適切なプロパティ(Status含む)を持っていることを確認

実行

ツールは以下のように実行します。

$ python pocket_to_notion.py

実行結果

おわりに

Pocketのサービス終了が2025年7月に迫っているということもあり、自分も無事に移行できて良かったです。 ただ、長年愛用してきたサービスがなくなる寂しさを感じています😢

完全なソースコード

今回作成したソースコードは、以下のGitHubリポジトリで公開しています。

github.com