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),
      };
    }

Tool Definition Quality

Score is being calculated. Check back soon.

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