note には API がありません。なのでプログラムからの自動投稿はできないのかな?と思ったけどそんなことはありません。非公開のAPIがあります。
でも非公開ってことは使われたくないんだろうなぁ、と思い別の方法をとります。WEBアプリのテストなどに使われるマイクロソフト製の Playwright というツールです。Chrome や Selenium を制御してテストなどに使うのですが、もちろん記事の投稿なんかもできます。
こちらが Playwright で投稿した記事です。
そしてこちらが Playwright の js コードです。
'use strict';
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright');
// 簡易な現在日時文字列生成
function nowStr() {
const d = new Date(); const z = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${z(d.getMonth()+1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`;
}
// 入力(環境変数で上書き可)
const STATE_PATH = process.env.STATE_PATH || '/home/node/data/auth/note.json';
const START_URL = process.env.START_URL || 'https://editor.note.com/new';
const TITLE_TEXT = process.env.TITLE || 'サンプルタイトル';
const BODY_TEXT = process.env.BODY || 'サンプル本文';
const THUMB_IMG = process.env.THUMB_IMG || ''; // サムネ画像のパス(/home/node/data/assets/...)
const TAGS_RAW = process.env.TAGS || ''; // 例: "AI, ニュース"(カンマ or 改行区切り)
const IS_PUBLIC = (process.env.NOTE_PUBLIC ?? 'true').toLowerCase() === 'true';
const TIMEOUT = parseInt(process.env.TIMEOUT_MS || '180000', 10);
const FINAL_SS = `/home/node/data/screenshots/note-post-simple-${nowStr()}.png`;
(async () => {
if (!fs.existsSync(STATE_PATH)) {
console.error('[post-simple] storageState not found:', STATE_PATH);
process.exit(1);
}
const browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'] });
const context = await browser.newContext({ storageState: STATE_PATH, locale: 'ja-JP' });
const page = await context.newPage();
// Playwright 全体の既定タイムアウトを拡大
page.setDefaultTimeout(TIMEOUT);
try {
await page.goto(START_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
await page.waitForSelector('textarea[placeholder*="タイトル"]', { timeout: TIMEOUT });
// === サムネ画像の設定(ヘッダー側) ================================
if (THUMB_IMG) {
// 1) 最上段の「画像を追加」を特定(本文側の誤クリック回避)
const candidates = page.locator('button[aria-label="画像を追加"]');
await candidates.first().waitFor({ state: 'visible', timeout: TIMEOUT });
let target = candidates.first();
const cnt = await candidates.count();
if (cnt > 1) {
let minY = Infinity, idx = 0;
for (let i = 0; i < cnt; i++) {
const box = await candidates.nth(i).boundingBox();
if (box && box.y < minY) { minY = box.y; idx = i; }
}
target = candidates.nth(idx);
}
await target.scrollIntoViewIfNeeded();
await target.click({ force: true });
// 2) 「画像をアップロード」→ filechooser(フォールバックあり)
const uploadBtn = page.locator('button:has-text("画像をアップロード")').first();
await uploadBtn.waitFor({ state: 'visible', timeout: TIMEOUT });
let chooser = null;
try {
[chooser] = await Promise.all([
page.waitForEvent('filechooser', { timeout: 5000 }),
uploadBtn.click({ force: true }),
]);
} catch (_) { /* 何もせずフォールバックへ */ }
if (chooser) {
await chooser.setFiles(THUMB_IMG);
} else {
// 一部実装では filechooser が発火しないため input[type=file] を直接操作
await uploadBtn.click({ force: true }).catch(()=>{});
const fileInput = page.locator('input[type="file"]').first();
await fileInput.waitFor({ state: 'attached', timeout: TIMEOUT });
await fileInput.setInputFiles(THUMB_IMG);
}
// 3) トリミングダイアログ内「保存」を押す(ダイアログにスコープ)
const dialog = page.locator('div[role="dialog"]');
await dialog.waitFor({ state: 'visible', timeout: TIMEOUT });
// 保存ボタン & cropper を取得
const saveThumbBtn = dialog.locator('button:has-text("保存")').first();
const cropper = dialog.locator('[data-testid="cropper"]').first();
// Locator → elementHandle に変換してから waitForFunction に渡す
const cropperEl = await cropper.elementHandle();
const saveEl = await saveThumbBtn.elementHandle();
await Promise.race([
page.waitForFunction(
el => getComputedStyle(el).pointerEvents === 'none',
cropperEl,
{ timeout: TIMEOUT }
),
page.waitForFunction(
el => !el.disabled,
saveEl,
{ timeout: TIMEOUT }
),
]);
await saveThumbBtn.click(); // force なしで確実にヒット
// ダイアログが閉じるのを待つ
await dialog.waitFor({ state: 'hidden', timeout: TIMEOUT }).catch(()=>{});
await page.waitForLoadState('networkidle', { timeout: TIMEOUT }).catch(()=>{});
// 4) 反映確認(CSP回避のため selector ベース)
// A) 「画像を変更」ボタンが出る B) 既存の「画像を追加」ボタンが消える
const changedBtn = page.locator('button[aria-label="画像を変更"]');
const addBtn = page.locator('button[aria-label="画像を追加"]');
let applied = false;
try { await changedBtn.waitFor({ state: 'visible', timeout: 5000 }); applied = true; } catch {}
if (!applied) {
try { await addBtn.waitFor({ state: 'hidden', timeout: 5000 }); applied = true; } catch {}
}
if (!applied) console.warn('[post-simple] サムネ反映確認が曖昧(続行します)');
}
// === サムネ画像の設定 ここまで =====================================
// タイトル設定
await page.fill('textarea[placeholder*="タイトル"]', TITLE_TEXT);
// === 本文設定(ロール or contenteditable で待機)====================
const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first();
await bodyBox.waitFor({ state: 'visible' }); // 差し替え完了を待つ
await bodyBox.click();
await page.keyboard.type(BODY_TEXT);
// URLが含まれる場合は最後に改行(Enter)を追加 → note の自動展開トリガー
if (/https?:\/\//.test(BODY_TEXT)) {
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
}
// ---- 公開 or 下書き保存 -------------------------------------------
if (!IS_PUBLIC) {
// ↓ 「下書き保存」ボタンを押して終了
const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first();
await saveBtn.waitFor({ state:'visible', timeout: TIMEOUT });
if (await saveBtn.isEnabled()) {
await saveBtn.click();
await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(()=>{});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(()=>{});
}
// スクショだけ撮って正常終了
fs.mkdirSync(path.dirname(FINAL_SS), { recursive: true });
await page.screenshot({ path: FINAL_SS, fullPage: true });
console.log('[post-simple] 下書き保存完了:', page.url());
console.log('[post-simple] スクリーンショット:', FINAL_SS);
return; // ここで処理終了(公開しない)
}
// === 「公開に進む」をクリック ===
const proceedBtn = page.locator('button:has-text("公開に進む")').first();
await proceedBtn.waitFor({ state: 'visible', timeout: TIMEOUT });
for (let i = 0; i < 20; i++) { if (await proceedBtn.isEnabled()) break; await page.waitForTimeout(100); }
await proceedBtn.click({ force: true });
// 公開ページ(/publish)へ遷移 or 「投稿する」ボタン表示を待つ
await Promise.race([
page.waitForURL(/\/publish/i, { timeout: TIMEOUT }).catch(()=>{}),
page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible', timeout: TIMEOUT }).catch(()=>{}),
]);
// === タグ入力(公開ページ)===
const tags = TAGS_RAW.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
if (tags.length) {
let tagInput = page.locator('input[placeholder*="ハッシュタグ"]'); // 例: placeholder="ハッシュタグを追加する"
if (!(await tagInput.count())) tagInput = page.locator('input[role="combobox"]').first();
await tagInput.waitFor({ state: 'visible', timeout: TIMEOUT });
for (const t of tags) {
await tagInput.click();
await tagInput.fill(t);
await page.keyboard.press('Enter'); // 候補選択 or 新規追加
await page.waitForTimeout(120);
}
}
// === 投稿する ===
const publishBtn = page.locator('button:has-text("投稿する")').first();
await publishBtn.waitFor({ state: 'visible', timeout: TIMEOUT });
for (let i = 0; i < 20; i++) { if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); }
await publishBtn.click({ force: true });
// 投稿完了待ち:/publish 以外へ遷移 or 完了トースト表示
await Promise.race([
page.waitForURL(url => !/\/publish/i.test(url), { timeout: 20000 }).catch(()=>{}),
page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(()=>{}),
page.waitForTimeout(5000),
]);
// スクリーンショット
fs.mkdirSync(path.dirname(FINAL_SS), { recursive: true });
await page.screenshot({ path: FINAL_SS, fullPage: true });
console.log('[post-simple] 記事作成完了:', page.url());
console.log('[post-simple] スクリーンショット:', FINAL_SS);
} catch (e) {
console.error('[post-simple] エラー発生:', e.message);
process.exit(1);
} finally {
await context.close();
await browser.close();
}
})();
とても長いですねぇ。
それもそのばずで note への投稿にはタイトルと本文以外にもサムネ画像のアップロードでダイアログが開いて位置調整などもします。投稿ボタンの後にタグを設定したりもします。それぞれのボタンをポチポチするぶんだけコードが長くなります。
コードを書いたのは全部AIなので書く苦労はないけどレビューする苦労は多めでした。
ChatGPT のエージェントモードも裏で Playwright がお仕事してるのかしら、とか思いながら今日もきょうとてAI関連のニュースを読みます。ほんとコード書かなくなったなぁ。