← 特典一覧に戻る
メルマガ登録特典
note マガジン別CSV出力 確認用コード
noteの記事一覧CSVボタンで、選んだマガジン以外の記事まで取得されてしまう問題を調査したときに使った確認用コード2本です。

背景

noteの記事タイトルとURLをCSVでダウンロードするボタンを作っていたとき、選んだマガジン以外の記事まで取得されてしまう問題にぶつかりました。

このとき効いたのは、うまくいかない完成コードを推測のまま直し続けるのをやめて、必要な情報を調べるための確認用コードを先に作ったことでした。

この2本は完成版のスクリプトとは別物です。普段使うものではなく、修正がループしたときに「何の情報が足りないのか」を確かめるための一時調査用コードです。

▶︎ 詳しい解説記事(note)

▶︎ 詳しい解説記事(Substack)

確認コード1:記事データにマガジン情報が入っているか確認する

最初は、記事一覧データの中に、その記事がどのマガジンに入っているかという情報があると思っていました。そこで、APIから1件だけ記事データを取得して、中身をJSONでダウンロードする確認コードを作りました。

この確認で、最初に使っていた取得先には、所属マガジンを判断できる情報が入っていないことがわかりました。

// ==UserScript==
// @name         note 記事一覧CSV出力(API中身確認版)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  note APIの取得結果にマガジン情報が入っているか確認するための一時調査版。
// @author       You
// @match        https://note.com/notes*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_CREATOR_ID = 'note_csv_creator_id';
    const BUTTON_ID = 'note-api-debug-btn';

    let running = false;

    function cleanCreatorId(value) {
        if (!value) return '';

        let text = String(value).trim();

        try {
            if (text.includes('note.com/')) {
                const url = new URL(text);
                text = url.pathname.split('/').filter(Boolean)[0] || '';
            }
        } catch (e) {}

        text = text.replace(/^@/, '').trim();

        const ng = new Set([
            'notes',
            'api',
            'login',
            'signup',
            'settings',
            'notifications',
            'help',
            'terms',
            'privacy'
        ]);

        if (ng.has(text)) return '';

        return text;
    }

    function detectCreatorIdFromPage() {
        const stored = cleanCreatorId(localStorage.getItem(STORAGE_CREATOR_ID));
        if (stored) return stored;

        const candidates = [];
        const anchors = Array.from(document.querySelectorAll('a[href]'));

        anchors.forEach(a => {
            try {
                const url = new URL(a.href, location.href);
                if (url.hostname !== 'note.com') return;

                const parts = url.pathname.split('/').filter(Boolean);

                if (parts.length >= 3 && parts[1] === 'n') {
                    candidates.push(parts[0]);
                }

                if (parts.length === 1) {
                    candidates.push(parts[0]);
                }
            } catch (e) {}
        });

        const nextData = document.getElementById('__NEXT_DATA__');
        if (nextData && nextData.textContent) {
            const text = nextData.textContent;
            const matches = text.matchAll(/"urlname"\s*:\s*"([^"]+)"/g);

            for (const match of matches) {
                candidates.push(match[1]);
            }
        }

        for (const candidate of candidates) {
            const cleaned = cleanCreatorId(candidate);

            if (cleaned) {
                localStorage.setItem(STORAGE_CREATOR_ID, cleaned);
                return cleaned;
            }
        }

        return '';
    }

    function askCreatorId() {
        const detected = detectCreatorIdFromPage();

        const input = prompt(
            'note IDを入力してください。\n\n例:https://note.com/chi3jp の場合は chi3jp',
            detected || ''
        );

        const creatorId = cleanCreatorId(input);

        if (creatorId) {
            localStorage.setItem(STORAGE_CREATOR_ID, creatorId);
        }

        return creatorId;
    }

    function extractApiItems(json) {
        const data = json && json.data ? json.data : json;

        if (!data) return [];

        if (Array.isArray(data.contents)) return data.contents;
        if (Array.isArray(data.notes)) return data.notes;
        if (Array.isArray(data.items)) return data.items;

        return [];
    }

    function findMagazineLikeKeys(obj, path = '', result = []) {
        if (!obj || typeof obj !== 'object') return result;

        Object.keys(obj).forEach(key => {
            const value = obj[key];
            const currentPath = path ? `${path}.${key}` : key;
            const lowerKey = key.toLowerCase();

            if (
                lowerKey.includes('magazine') ||
                lowerKey.includes('circle') ||
                lowerKey.includes('membership') ||
                lowerKey.includes('container') ||
                lowerKey.includes('group') ||
                key.includes('マガジン')
            ) {
                result.push({
                    path: currentPath,
                    value: value
                });
            }

            if (value && typeof value === 'object') {
                findMagazineLikeKeys(value, currentPath, result);
            }
        });

        return result;
    }

    function downloadJson(data) {
        const text = JSON.stringify(data, null, 2);

        const blob = new Blob([text], {
            type: 'application/json;charset=utf-8;'
        });

        const link = document.createElement('a');
        const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        const objectUrl = URL.createObjectURL(blob);

        link.href = objectUrl;
        link.setAttribute('download', `note_api_sample_${dateStr}.json`);

        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        URL.revokeObjectURL(objectUrl);
    }

    async function debugApi() {
        if (running) return;

        running = true;

        try {
            const creatorId = detectCreatorIdFromPage() || askCreatorId();

            if (!creatorId) {
                alert('note IDが入力されていません。');
                return;
            }

            const apiUrl = `/api/v2/creators/${encodeURIComponent(creatorId)}/contents?kind=note&page=1`;

            const response = await fetch(apiUrl, {
                credentials: 'include'
            });

            if (!response.ok) {
                throw new Error(`API取得に失敗しました。status=${response.status}`);
            }

            const json = await response.json();
            const items = extractApiItems(json);
            const firstItem = items[0] || null;

            if (!firstItem) {
                alert('APIから記事データを取得できませんでした。');
                return;
            }

            const magazineLikeKeys = findMagazineLikeKeys(firstItem);

            const debugData = {
                checkedAt: new Date().toISOString(),
                creatorId,
                apiUrl,
                itemCountOnFirstPage: items.length,
                firstItemTopLevelKeys: Object.keys(firstItem),
                magazineLikeKeys,
                firstItem
            };

            console.log('note API debug data:', debugData);
            downloadJson(debugData);

            alert('確認用JSONをダウンロードしました。');

        } catch (error) {
            console.error(error);
            alert(`API確認中にエラーが発生しました。\n\n${error.message}`);
        } finally {
            running = false;
        }
    }

    setTimeout(debugApi, 1000);
})();

確認コード2:マガジン選択時の通信を調べる

記事データそのものにマガジン情報がないとわかったので、次は、noteでマガジンを選んだとき、裏でどんな通信が行われているのかを調べました。

この確認コードでは、noteの記事管理画面で呼ばれるAPI通信を記録し、JSONとしてダウンロードしました。その結果、マガジン選択時には、magazine_id を付けた取得先が使われていることがわかりました。

// ==UserScript==
// @name         note 記事一覧CSV出力(API通信調査版)
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  noteの記事一覧画面で、マガジン選択時に呼ばれるAPIを調査する一時版。
// @author       You
// @match        https://note.com/notes*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_KEY = 'note_csv_api_network_logs';

    window.__noteCsvApiLogs = window.__noteCsvApiLogs || [];

    function now() {
        return new Date().toISOString();
    }

    function saveLogs() {
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(window.__noteCsvApiLogs.slice(-200)));
        } catch (e) {
            console.warn('ログ保存に失敗しました', e);
        }
    }

    function addLog(log) {
        window.__noteCsvApiLogs.push({
            checkedAt: now(),
            ...log
        });

        if (window.__noteCsvApiLogs.length > 200) {
            window.__noteCsvApiLogs = window.__noteCsvApiLogs.slice(-200);
        }

        saveLogs();
    }

    function shouldLogUrl(url) {
        const text = String(url || '');

        if (!text.includes('/api/')) return false;

        return (
            text.includes('/notes') ||
            text.includes('/contents') ||
            text.includes('/magazine') ||
            text.includes('/magazines') ||
            text.includes('/creator') ||
            text.includes('/creators') ||
            text.includes('/publish') ||
            text.includes('/dashboard')
        );
    }

    function summarizeJson(json) {
        const summary = {};

        if (!json || typeof json !== 'object') {
            summary.type = typeof json;
            return summary;
        }

        summary.topLevelKeys = Object.keys(json);

        const data = json.data || json;

        if (data && typeof data === 'object') {
            summary.dataKeys = Object.keys(data);

            const possibleArrays = [
                data.contents,
                data.notes,
                data.items,
                data.magazines,
                data.magazineNotes,
                data.userNotes
            ];

            const arr = possibleArrays.find(v => Array.isArray(v));

            if (arr) {
                summary.arrayLength = arr.length;

                if (arr[0] && typeof arr[0] === 'object') {
                    summary.firstItemKeys = Object.keys(arr[0]);

                    summary.firstItemSample = {};
                    [
                        'id',
                        'key',
                        'name',
                        'title',
                        'noteUrl',
                        'url',
                        'status',
                        'publishAt',
                        'magazineId',
                        'magazineKey',
                        'magazineName',
                        'magazine',
                        'labels'
                    ].forEach(k => {
                        if (k in arr[0]) {
                            summary.firstItemSample[k] = arr[0][k];
                        }
                    });
                }
            }
        }

        return summary;
    }

    const originalFetch = window.fetch;

    window.fetch = async function(...args) {
        const requestUrl = args[0] && args[0].url ? args[0].url : args[0];
        const method = args[1]?.method || 'GET';

        const response = await originalFetch.apply(this, args);

        try {
            const urlText = String(requestUrl || '');

            if (shouldLogUrl(urlText)) {
                const cloned = response.clone();

                cloned.json().then(json => {
                    addLog({
                        type: 'fetch',
                        method,
                        url: urlText,
                        status: response.status,
                        ok: response.ok,
                        summary: summarizeJson(json)
                    });
                }).catch(() => {
                    addLog({
                        type: 'fetch',
                        method,
                        url: urlText,
                        status: response.status,
                        ok: response.ok,
                        summary: {
                            note: 'JSONとして読めませんでした'
                        }
                    });
                });
            }
        } catch (e) {
            console.warn('fetchログ取得に失敗しました', e);
        }

        return response;
    };

    function downloadJson(data) {
        const text = JSON.stringify(data, null, 2);

        const blob = new Blob([text], {
            type: 'application/json;charset=utf-8;'
        });

        const link = document.createElement('a');
        const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        const objectUrl = URL.createObjectURL(blob);

        link.href = objectUrl;
        link.setAttribute('download', `note_api_network_logs_${dateStr}.json`);

        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        URL.revokeObjectURL(objectUrl);
    }

    window.addEventListener('keydown', e => {
        if (e.altKey && e.key.toLowerCase() === 'l') {
            const logs = window.__noteCsvApiLogs || [];

            if (!logs.length) {
                alert('API通信ログがまだありません。');
                return;
            }

            downloadJson({
                checkedAt: now(),
                pageUrl: location.href,
                logCount: logs.length,
                logs
            });

            alert('API通信ログJSONをダウンロードしました。');
        }
    });
})();
← 特典一覧に戻る