Skip to main content
Glama

publish_note

Publishes articles to note.com by automatically extracting title, content, and tags from Markdown files and posting them directly to your account.

Instructions

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

Input Schema

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

Implementation Reference

  • Tool handler within the MCP callToolRequestHandler that parses input parameters using the schema and invokes the postToNote function with isPublic: true to publish the article on note.com.
    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 defining the input parameters and validation for 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)
    Registration of the publish_note tool in the TOOLS array, which is returned by the listTools MCP handler, including name, description, and JSON schema.
    {
      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 performs the actual browser automation to post or publish the note on note.com using Playwright. Called by both publish_note and save_draft handlers.
    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;
      }
    }
  • Helper function to parse Markdown file content, extracting title from frontmatter or H1, tags from frontmatter array or list, and body text.
    function parseMarkdown(content: string): {
      title: string;
      body: string;
      tags: string[];
    } {
      const lines = content.split('\n');
      let title = '';
      let body = '';
      const tags: string[] = [];
      let inFrontMatter = false;
      let frontMatterEnded = false;
    
      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
    
        // Front matter の処理(YAML形式)
        if (line.trim() === '---') {
          if (!frontMatterEnded) {
            inFrontMatter = !inFrontMatter;
            if (!inFrontMatter) {
              frontMatterEnded = true;
            }
            continue;
          }
        }
    
        if (inFrontMatter) {
          // タイトルとタグをfront matterから抽出
          if (line.startsWith('title:')) {
            title = line.substring(6).trim().replace(/^["']|["']$/g, '');
          } else if (line.startsWith('tags:')) {
            const tagsStr = line.substring(5).trim();
            if (tagsStr.startsWith('[') && tagsStr.endsWith(']')) {
              // 配列形式: tags: [tag1, tag2]
              tags.push(...tagsStr.slice(1, -1).split(',').map(t => t.trim().replace(/^["']|["']$/g, '')));
            }
          } else if (line.trim().startsWith('-')) {
            // 配列形式: - tag1
            const tag = line.trim().substring(1).trim().replace(/^["']|["']$/g, '');
            if (tag) tags.push(tag);
          }
          continue;
        }
    
        // タイトルを # から抽出(front matterがない場合)
        if (!title && line.startsWith('# ')) {
          title = line.substring(2).trim();
          continue;
        }
    
        // 本文を追加
        if (frontMatterEnded || !line.trim().startsWith('---')) {
          body += line + '\n';
        }
      }
    
      return {
        title: title || 'Untitled',
        body: body.trim(),
        tags: tags.filter(Boolean),
      };
    }
Install Server

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