noteの記事タイトルとURLをCSVでダウンロードするボタンを作っていたとき、選んだマガジン以外の記事まで取得されてしまう問題にぶつかりました。
このとき効いたのは、うまくいかない完成コードを推測のまま直し続けるのをやめて、必要な情報を調べるための確認用コードを先に作ったことでした。
最初は、記事一覧データの中に、その記事がどのマガジンに入っているかという情報があると思っていました。そこで、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);
})();
記事データそのものにマガジン情報がないとわかったので、次は、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をダウンロードしました。');
}
});
})();