Skip to main content
Glama

publish_note

Publish articles to note.com by reading title, body text, and tags from Markdown files. Automatically posts content to your account using authentication state.

Instructions

note.comに記事を公開します。Markdownファイルからタイトル、本文、タグを読み取り、自動的に投稿します。

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
markdown_pathYesMarkdownファイルのパス(タイトル、本文、タグを含む)
screenshot_dirNoスクリーンショット保存ディレクトリ(オプション)
state_pathNonote.comの認証状態ファイルのパス(デフォルト: /app/.note-state.json)
thumbnail_pathNoサムネイル画像のパス(オプション)
timeoutNoタイムアウト(ミリ秒、デフォルト: 120000)

Implementation Reference

  • Tool dispatch handler for 'publish_note': parses input arguments using PublishNoteSchema, invokes the postToNote function with isPublic=true, and returns the result as JSON.
    if (name === 'publish_note') { const params = PublishNoteSchema.parse(args); const result = await postToNote({ markdownPath: params.markdown_path, thumbnailPath: params.thumbnail_path, statePath: params.state_path, screenshotDir: params.screenshot_dir, timeout: params.timeout, isPublic: true, }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; }
  • Zod schema for validating input parameters of the publish_note tool.
    const PublishNoteSchema = z.object({ markdown_path: z.string().describe('Markdownファイルのパス(タイトル、本文、タグを含む)'), thumbnail_path: z.string().optional().describe('サムネイル画像のパス(オプション)'), state_path: z.string().optional().describe(`note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`), screenshot_dir: z.string().optional().describe('スクリーンショット保存ディレクトリ(オプション)'), timeout: z.number().optional().describe(`タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`), });
  • src/index.ts:611-640 (registration)
    Tool registration object in the TOOLS array, defining name, description, and inputSchema for publish_note.
    { name: 'publish_note', description: 'note.comに記事を公開します。Markdownファイルからタイトル、本文、タグを読み取り、自動的に投稿します。', inputSchema: { type: 'object', properties: { markdown_path: { type: 'string', description: 'Markdownファイルのパス(タイトル、本文、タグを含む)', }, thumbnail_path: { type: 'string', description: 'サムネイル画像のパス(オプション)', }, state_path: { type: 'string', description: `note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`, }, screenshot_dir: { type: 'string', description: 'スクリーンショット保存ディレクトリ(オプション)', }, timeout: { type: 'number', description: `タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`, }, }, required: ['markdown_path'], }, },
  • Core helper function that implements the posting logic to note.com: parses Markdown, handles images and thumbnails, automates browser with Playwright, supports public publish or draft save.
    async function postToNote(params: { markdownPath: string; thumbnailPath?: string; statePath?: string; isPublic: boolean; screenshotDir?: string; timeout?: number; }): Promise<{ success: boolean; url: string; screenshot?: string; message: string; }> { const { markdownPath, thumbnailPath, statePath = DEFAULT_STATE_PATH, isPublic, screenshotDir = path.join(os.tmpdir(), 'note-screenshots'), timeout = DEFAULT_TIMEOUT, } = params; // Markdownファイルを読み込み if (!fs.existsSync(markdownPath)) { throw new Error(`Markdown file not found: ${markdownPath}`); } const mdContent = fs.readFileSync(markdownPath, 'utf-8'); const { title, body, tags } = parseMarkdown(mdContent); // 本文中の画像を抽出 const baseDir = path.dirname(markdownPath); const images = extractImages(body, baseDir); log('Parsed markdown', { title, bodyLength: body.length, tags, imageCount: images.length }); // 認証状態ファイルを確認 if (!fs.existsSync(statePath)) { throw new Error(`State file not found: ${statePath}. Please login first.`); } // スクリーンショットディレクトリを作成 fs.mkdirSync(screenshotDir, { recursive: true }); const screenshotPath = path.join(screenshotDir, `note-post-${nowStr()}.png`); const browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'], }); try { const context = await browser.newContext({ storageState: statePath, locale: 'ja-JP', permissions: ['clipboard-read', 'clipboard-write'], }); const page = await context.newPage(); page.setDefaultTimeout(timeout); // クリップボード権限を明示的に付与 await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://editor.note.com' }); // 新規記事作成ページに移動 const startUrl = 'https://editor.note.com/new'; await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout }); await page.waitForSelector('textarea[placeholder*="タイトル"]', { timeout }); // サムネイル画像の設定 if (thumbnailPath && fs.existsSync(thumbnailPath)) { log('Uploading thumbnail image'); const candidates = page.locator('button[aria-label="画像を追加"]'); await candidates.first().waitFor({ state: 'visible', timeout }); let target = candidates.first(); const cnt = await candidates.count(); if (cnt > 1) { let minY = Infinity; let 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 }); const uploadBtn = page.locator('button:has-text("画像をアップロード")').first(); await uploadBtn.waitFor({ state: 'visible', timeout }); let chooser = null; try { [chooser] = await Promise.all([ page.waitForEvent('filechooser', { timeout: 5000 }), uploadBtn.click({ force: true }), ]); } catch (_) { // フォールバック } if (chooser) { await chooser.setFiles(thumbnailPath); } else { await uploadBtn.click({ force: true }).catch(() => {}); const fileInput = page.locator('input[type="file"]').first(); await fileInput.waitFor({ state: 'attached', timeout }); await fileInput.setInputFiles(thumbnailPath); } // トリミングダイアログ内「保存」を押す const dialog = page.locator('div[role="dialog"]'); await dialog.waitFor({ state: 'visible', timeout }); const saveThumbBtn = dialog.locator('button:has-text("保存")').first(); const cropper = dialog.locator('[data-testid="cropper"]').first(); const cropperEl = await cropper.elementHandle(); const saveEl = await saveThumbBtn.elementHandle(); if (cropperEl && saveEl) { await Promise.race([ page.waitForFunction( (el) => getComputedStyle(el as Element).pointerEvents === 'none', cropperEl, { timeout } ), page.waitForFunction( (el) => !(el as HTMLButtonElement).disabled, saveEl, { timeout } ), ]); } await saveThumbBtn.click(); await dialog.waitFor({ state: 'hidden', timeout }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout }).catch(() => {}); // 反映確認 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) { log('Thumbnail reflection uncertain, continuing'); } } // タイトル設定 await page.fill('textarea[placeholder*="タイトル"]', title); log('Title set'); // 本文設定(行ごとに処理してURLをリンクカードに変換、画像を埋め込む) const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first(); await bodyBox.waitFor({ state: 'visible' }); await bodyBox.click(); const lines = body.split('\n'); let previousLineWasList = false; // 前の行がリスト項目だったかを追跡 let previousLineWasQuote = false; // 前の行が引用だったかを追跡 let previousLineWasHorizontalRule = false; // 前の行が水平線だったかを追跡 for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isLastLine = i === lines.length - 1; // コードブロックの開始を検出 if (line.trim().startsWith('```')) { // コードブロック全体(```から```まで)を収集 const codeBlockLines: string[] = [line]; // 開始行を含める let j = i + 1; // 終了行まで収集 while (j < lines.length) { codeBlockLines.push(lines[j]); if (lines[j].trim().startsWith('```')) { break; // 終了行を含めて終了 } j++; } // コードブロック全体をクリップボードにコピー const codeBlockContent = codeBlockLines.join('\n'); await page.evaluate((text) => { return navigator.clipboard.writeText(text); }, codeBlockContent); await page.waitForTimeout(200); // ペースト const isMac = process.platform === 'darwin'; if (isMac) { await page.keyboard.press('Meta+v'); } else { await page.keyboard.press('Control+v'); } await page.waitForTimeout(300); // コードブロックの後に改行(最終行でない場合) if (j < lines.length - 1) { await page.keyboard.press('Enter'); } // iをコードブロック終了行まで進める i = j; // フラグをリセット previousLineWasList = false; previousLineWasQuote = false; previousLineWasHorizontalRule = false; continue; } // 次の行が水平線かどうかをチェック const nextLine = i < lines.length - 1 ? lines[i + 1] : ''; const nextLineIsHorizontalRule = nextLine.trim() === '---'; // 水平線の直後の空行をスキップ if (previousLineWasHorizontalRule && line.trim() === '') { previousLineWasHorizontalRule = false; continue; // 空行をスキップ } previousLineWasHorizontalRule = false; // 画像マークダウンを検出 const imageMatch = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); if (imageMatch) { const imagePath = imageMatch[2]; // ローカルパスの画像をアップロード if (!imagePath.startsWith('http://') && !imagePath.startsWith('https://')) { const imageInfo = images.find(img => img.localPath === imagePath); if (imageInfo && fs.existsSync(imageInfo.absolutePath)) { log('Pasting inline image', { path: imageInfo.absolutePath }); // 画像をクリップボードにコピーしてペーストする方法 // 1. 改行して新しい行を作成 await page.keyboard.press('Enter'); await page.waitForTimeout(300); // 2. 画像ファイルをクリップボードにコピー const imageBuffer = fs.readFileSync(imageInfo.absolutePath); const base64Image = imageBuffer.toString('base64'); const mimeType = imageInfo.absolutePath.endsWith('.png') ? 'image/png' : imageInfo.absolutePath.endsWith('.jpg') || imageInfo.absolutePath.endsWith('.jpeg') ? 'image/jpeg' : imageInfo.absolutePath.endsWith('.gif') ? 'image/gif' : 'image/png'; // クリップボードに画像を設定するためのJavaScriptを実行 await page.evaluate(async ({ base64, mime }) => { const response = await fetch(`data:${mime};base64,${base64}`); const blob = await response.blob(); const item = new ClipboardItem({ [mime]: blob }); await navigator.clipboard.write([item]); }, { base64: base64Image, mime: mimeType }); await page.waitForTimeout(500); // 3. Cmd+V (macOS) または Ctrl+V でペースト const isMac = process.platform === 'darwin'; if (isMac) { await page.keyboard.press('Meta+v'); } else { await page.keyboard.press('Control+v'); } // ペースト完了を待つ await page.waitForTimeout(2000); log('Inline image pasted'); // 画像の後に改行してテキストボックスに戻る if (!isLastLine) { await page.keyboard.press('Enter'); } previousLineWasList = false; // 画像の後はリストではない previousLineWasQuote = false; // 画像の後は引用ではない previousLineWasHorizontalRule = false; // 画像の後は水平線ではない continue; // 次の行へ } } } // 水平線かどうかをチェック const isHorizontalRule = line.trim() === '---'; // 現在の行がリスト項目かどうかをチェック const isBulletList = /^(\s*)- /.test(line); const isNumberedList = /^(\s*)\d+\.\s/.test(line); const isCurrentLineList = isBulletList || isNumberedList; // 現在の行が引用かどうかをチェック const isQuote = /^>/.test(line); // 通常のテキスト行を入力 let processedLine = line; // 前の行がリスト項目で、現在の行もリスト項目なら、マークダウン記号を削除 if (previousLineWasList && isCurrentLineList) { // 箇条書きリスト: "- " または " - " などを削除 // 先頭のスペース(インデント)を保持しつつ、"- " だけを削除 if (isBulletList) { processedLine = processedLine.replace(/^(\s*)- /, '$1'); } // 番号付きリスト: "1. " または " 1. " などを削除 // 先頭のスペース(インデント)を保持しつつ、"数字. " だけを削除 if (isNumberedList) { processedLine = processedLine.replace(/^(\s*)\d+\.\s/, '$1'); } } // 前の行が引用で、現在の行も引用なら、マークダウン記号を削除 if (previousLineWasQuote && isQuote) { // 引用: "> " を削除 processedLine = processedLine.replace(/^>\s?/, ''); } await page.keyboard.type(processedLine); // 次の行のために、現在の行の状態を記録 previousLineWasList = isCurrentLineList; previousLineWasQuote = isQuote; previousLineWasHorizontalRule = isHorizontalRule; // URL単独行の場合、追加でEnterを押してリンクカード化をトリガー const isUrlLine = /^https?:\/\/[^\s]+$/.test(line.trim()); if (isUrlLine) { await page.keyboard.press('Enter'); // リンクカード展開のアニメーション完了を待機 await page.waitForTimeout(1200); // 次の行がある場合、キャレットがカード内に残らないよう、確実に次段落へ移動 if (!isLastLine) { await page.keyboard.press('ArrowDown'); await page.waitForTimeout(150); } } else { // URL以外の行の場合のみ、最後の行でなければ改行 if (!isLastLine) { await page.keyboard.press('Enter'); } } } log('Body set'); // 下書き保存の場合 if (!isPublic) { const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first(); await saveBtn.waitFor({ state: 'visible', timeout }); if (await saveBtn.isEnabled()) { await saveBtn.click(); await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); } await page.screenshot({ path: screenshotPath, fullPage: true }); const finalUrl = page.url(); log('Draft saved', { url: finalUrl }); await context.close(); await browser.close(); return { success: true, url: finalUrl, screenshot: screenshotPath, message: '下書きを保存しました', }; } // 公開に進む const proceedBtn = page.locator('button:has-text("公開に進む")').first(); await proceedBtn.waitFor({ state: 'visible', timeout }); for (let i = 0; i < 20; i++) { if (await proceedBtn.isEnabled()) break; await page.waitForTimeout(100); } await proceedBtn.click({ force: true }); // 公開ページへ遷移 await Promise.race([ page.waitForURL(/\/publish/i, { timeout }).catch(() => {}), page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible', timeout }).catch(() => {}), ]); // タグ入力 if (tags.length > 0) { log('Adding tags', { tags }); let tagInput = page.locator('input[placeholder*="ハッシュタグ"]'); if (!(await tagInput.count())) { tagInput = page.locator('input[role="combobox"]').first(); } await tagInput.waitFor({ state: 'visible', timeout }); for (const tag of tags) { await tagInput.click(); await tagInput.fill(tag); await page.keyboard.press('Enter'); await page.waitForTimeout(120); } } // 投稿する const publishBtn = page.locator('button:has-text("投稿する")').first(); await publishBtn.waitFor({ state: 'visible', timeout }); for (let i = 0; i < 20; i++) { if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); } await publishBtn.click({ force: true }); // 投稿完了待ち await Promise.race([ page.waitForURL((url) => !/\/publish/i.test(url.toString()), { timeout: 20000 }).catch(() => {}), page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(() => {}), page.waitForTimeout(5000), ]); await page.screenshot({ path: screenshotPath, fullPage: true }); const finalUrl = page.url(); log('Published', { url: finalUrl }); await context.close(); await browser.close(); return { success: true, url: finalUrl, screenshot: screenshotPath, message: '記事を公開しました', }; } catch (error) { await browser.close(); throw error; } }

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Go-555/note-post-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server