diff --git a/.gitignore b/.gitignore index 8e98436..873e0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,11 @@ jspm_packages/ medium-session.json playwright-session.json session-*.json +# Persistent auth session + Chrome profile (contain login cookies — never commit) +.medium-mcp-session.json +.medium-mcp-chrome/ +*-session.json +*.session.json # Test files (development only) test-*.js diff --git a/src/browser-client.ts b/src/browser-client.ts index f274038..af82376 100644 --- a/src/browser-client.ts +++ b/src/browser-client.ts @@ -1,6 +1,7 @@ -import { chromium, Browser, Page, BrowserContext } from 'playwright'; -import { writeFileSync, readFileSync, existsSync } from 'fs'; +import { chromium, BrowserContext, Page } from 'playwright'; import { join } from 'path'; +import { homedir } from 'os'; +import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs'; export interface MediumArticle { title: string; @@ -18,434 +19,310 @@ export interface PublishOptions { isDraft?: boolean; } +// IMPORTANT: this UA must stay CONSISTENT between the visible login window and +// the headless background runs. Cloudflare ties its `cf_clearance` cookie to the +// exact User-Agent; if the headed login and the headless work use different UAs +// (e.g. real "Chrome" vs "HeadlessChrome"), the clearance is rejected and every +// headless navigation gets stuck on the "Just a moment…" bot challenge. +// It should also roughly match the bundled Chromium major version. +const USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; + +const LAUNCH_ARGS = [ + '--no-first-run', + '--no-default-browser-check', + '--disable-blink-features=AutomationControlled', +]; + +/** + * Browser-based Medium client backed by a PERSISTENT Chrome profile. + * + * Design notes (the hard-won bits): + * - A persistent user-data dir keeps cookies WITH an expiry across restarts, but + * Chromium drops pure *session* cookies (Medium's `sid`/`xsrf`) when a context + * is closed and reopened. So we ALSO snapshot the full storage state to + * `sessionFile` and re-inject it on every launch — this is what carries the + * login through the headed→headless switch and across server restarts. + * - Cloudflare guards the logged-in pages. We pass it by (a) keeping the UA + * identical between headed login and headless work, and (b) waiting out the + * "Just a moment…" interstitial after each navigation. + * - "Logged in" is verified from the actual page (absence of the header Sign-in + * button), not just cookie presence — Medium sets `sid`/`uid` for anonymous + * visitors too, so cookie presence alone is not proof of authentication. + */ export class BrowserMediumClient { - private browser: Browser | null = null; private context: BrowserContext | null = null; private page: Page | null = null; - private sessionPath = join(process.cwd(), 'medium-session.json'); - - async initialize(): Promise { - this.browser = await chromium.launch({ - headless: false, // Keep visible for login - slowMo: 100, // Slow down for reliability - args: [ - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--disable-features=VizDisplayCompositor', - '--disable-web-security', - '--disable-features=TranslateUI', - '--disable-ipc-flooding-protection', - '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - ] - }); + private currentHeadless: boolean | null = null; - // Load existing session if available - const contextOptions: any = { - viewport: { width: 1280, height: 720 }, - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - extraHTTPHeaders: { - 'Accept-Language': 'en-US,en;q=0.9' - } - }; - - if (existsSync(this.sessionPath)) { - try { - const sessionData = JSON.parse(readFileSync(this.sessionPath, 'utf8')); - contextOptions.storageState = sessionData; - } catch (error) { - console.error('Failed to load session:', error); - } + // Stable locations, independent of where the MCP server is launched from. + private userDataDir = join(homedir(), '.medium-mcp-chrome'); + private sessionFile = join(homedir(), '.medium-mcp-session.json'); + + private readonly PUBLISHED_URL = 'https://medium.com/me/stories?tab=posts-published'; + + /** Kept for API compatibility. Browser launches lazily on first use. */ + async initialize(): Promise {} + + // --------------------------------------------------------------------------- + // Browser lifecycle + // --------------------------------------------------------------------------- + + private async ensureBrowser(headless: boolean): Promise { + if (this.context && this.currentHeadless === headless && this.page && !this.page.isClosed()) { + return; + } + + if (this.context) { + await this.context.close().catch(() => {}); + this.context = null; + this.page = null; } - this.context = await this.browser.newContext(contextOptions); - - // Add script to remove webdriver property + mkdirSync(this.userDataDir, { recursive: true }); + + this.context = await chromium.launchPersistentContext(this.userDataDir, { + headless, + viewport: { width: 1280, height: 800 }, + userAgent: USER_AGENT, + args: LAUNCH_ARGS, + }); + await this.context.addInitScript(() => { - Object.defineProperty(navigator, 'webdriver', { - get: () => undefined, - }); - - // Remove automation indicators - delete (window as any).cdc_adoQpoasnfa76pfcZLmcfl_Array; - delete (window as any).cdc_adoQpoasnfa76pfcZLmcfl_Promise; - delete (window as any).cdc_adoQpoasnfa76pfcZLmcfl_Symbol; + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }); - - this.page = await this.context.newPage(); - } - async ensureLoggedIn(): Promise { - if (!this.page) throw new Error('Browser not initialized'); + this.page = this.context.pages()[0] ?? (await this.context.newPage()); + this.currentHeadless = headless; - // First check if we have a saved session - if (existsSync(this.sessionPath)) { - console.error('💾 Found existing session file, testing login status...'); + // Re-inject saved cookies (incl. session cookies the on-disk profile drops), + // unless the fresh context already carries an authenticated cookie. + if (!(await this.hasAuthCookie())) { + await this.seedFromSessionFile(); } + } - // Try a simpler page first to check login status - await this.page.goto('https://medium.com'); - await this.page.waitForLoadState('networkidle'); - - // Check if we're logged in by looking for user-specific elements + /** Heuristic: `xsrf` is set by Medium only for an authenticated session. */ + private async hasAuthCookie(): Promise { + if (!this.context) return false; + const cookies = await this.context.cookies('https://medium.com'); + return cookies.some(c => c.name === 'xsrf'); + } + + private async seedFromSessionFile(): Promise { + if (!this.context || !existsSync(this.sessionFile)) return; try { - // Try multiple selectors for logged-in state - const loginSelectors = [ - '[data-testid="headerUserButton"]', - '.avatar', - '[data-testid="user-menu"]', - 'button[aria-label*="user"]', - 'img[alt*="avatar"]', - '[data-testid="write-button"]', // Write button only appears when logged in - 'a[href="/me/stories"]' - ]; - - let isLoggedIn = false; - for (const selector of loginSelectors) { - try { - await this.page.waitForSelector(selector, { timeout: 3000 }); - console.error(`✅ Login detected using selector: ${selector}`); - isLoggedIn = true; - break; - } catch { - // Try next selector - } - } - - if (isLoggedIn) { - console.error('✅ Already logged in to Medium'); - await this.saveSession(); - return true; - } else { - throw new Error('Not logged in'); - } - } catch { - console.error('❌ Not logged in. Please log in manually...'); - - // Navigate to login page - await this.page.goto('https://medium.com/m/signin'); - - // Wait for user to complete login - console.error('⏳ Waiting for you to complete login in the browser...'); - console.error(''); - console.error('🔐 LOGIN INSTRUCTIONS:'); - console.error(' 1. In the opened browser, choose "Sign in with email"'); - console.error(' 2. Use your Medium email/password (avoid Google login if possible)'); - console.error(' 3. If you must use Google login:'); - console.error(' - Try clicking "Sign in with Google"'); - console.error(' - If blocked, manually navigate to medium.com in a regular browser'); - console.error(' - Login there first, then come back to this automated browser'); - console.error(' 4. Complete any 2FA if prompted'); - console.error(' 5. The script will continue automatically once logged in...'); - console.error(''); - - // Wait for successful login (user button appears) - try { - await this.page.waitForSelector('[data-testid="headerUserButton"], .avatar, [data-testid="user-menu"]', { timeout: 300000 }); // 5 minutes - console.error('✅ Login successful!'); - await this.saveSession(); - return true; - } catch (error) { - console.error('❌ Login timeout. Please try again.'); - return false; + const state = JSON.parse(readFileSync(this.sessionFile, 'utf8')); + if (Array.isArray(state.cookies) && state.cookies.length > 0) { + await this.context.addCookies(state.cookies); } + } catch (error) { + console.error('Failed to restore session cookies:', error); } } - async saveSession(): Promise { + private async persistState(): Promise { if (!this.context) return; - try { - const sessionData = await this.context.storageState(); - writeFileSync(this.sessionPath, JSON.stringify(sessionData, null, 2)); - console.error('💾 Session saved for future use'); + const state = await this.context.storageState(); + writeFileSync(this.sessionFile, JSON.stringify(state)); } catch (error) { - console.error('Failed to save session:', error); + console.error('Failed to persist session state:', error); + } + } + + // --------------------------------------------------------------------------- + // Cloudflare + auth helpers + // --------------------------------------------------------------------------- + + /** Wait out the Cloudflare "Just a moment…" interstitial after a navigation. */ + private async waitForChallenge(timeoutMs = 45000): Promise { + if (!this.page) return; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = (await this.page.title().catch(() => '')) || ''; + const isChallenge = + /just a moment|bir dakika|lütfen|moment|checking|security verif|doğrulama|verify you/i.test(title); + if (!isChallenge) break; + await this.page.waitForTimeout(1500); + } + // Small settle delay for the SPA to hydrate after the challenge clears. + await this.page.waitForTimeout(1500); + } + + /** Is the currently loaded page authenticated? (No header Sign-in button.) */ + private async isPageLoggedIn(): Promise { + if (!this.page) return false; + return await this.page + .evaluate(() => !document.querySelector('[data-testid="headerSignInButton"]') && + !!document.querySelector('[data-testid="headerUserIcon"], [data-testid="headerWriteButton"]')) + .catch(() => false); + } + + /** + * Ensure we have an authenticated session. Returns instantly if already logged + * in (headless/background). Otherwise opens ONE visible window for sign-in, + * then drops back to headless. The login is reused silently afterwards. + */ + async ensureLoggedIn(): Promise { + // Fast path: verify auth in the headless background context. + await this.ensureBrowser(true); + if (await this.verifyLoggedIn()) return true; + + // Need the user: relaunch the SAME profile with a visible window. + console.error('🔐 No valid session — opening a one-time login window...'); + await this.ensureBrowser(false); + if (!this.page) throw new Error('Browser not initialized'); + + await this.page.goto('https://medium.com/m/signin', { waitUntil: 'domcontentloaded' }); + console.error('🔐 Complete sign-in (and any 2FA) in the opened window. This is a ONE-TIME step.'); + + // Poll until Medium issues the authenticated `xsrf` cookie. + const start = Date.now(); + let authed = false; + while (Date.now() - start < 300000) { + if (await this.hasAuthCookie()) { authed = true; break; } + await this.page.waitForTimeout(1500); + } + + if (!authed) { + console.error('❌ Login timed out after 5 minutes.'); + return false; } + + // Snapshot the full session NOW (captures session cookies + cf_clearance), + // then return to a headless background context that re-seeds from it. + await this.persistState(); + console.error('✅ Login captured. Switching to headless background mode...'); + await this.ensureBrowser(true); + return await this.verifyLoggedIn(); + } + + /** Navigate to the homepage, clear Cloudflare, and confirm authentication. */ + private async verifyLoggedIn(): Promise { + if (!this.page) return false; + await this.page.goto('https://medium.com', { waitUntil: 'domcontentloaded' }); + await this.waitForChallenge(); + const ok = await this.isPageLoggedIn(); + if (ok) await this.persistState(); + return ok; } + // --------------------------------------------------------------------------- + // Tools + // --------------------------------------------------------------------------- + + /** Retrieve the signed-in user's own published stories. */ async getUserArticles(): Promise { + if (!(await this.ensureLoggedIn())) { + throw new Error('Medium login was not completed (timed out or cancelled).'); + } if (!this.page) throw new Error('Browser not initialized'); - - await this.ensureLoggedIn(); - - // Navigate to user's stories - await this.page.goto('https://medium.com/me/stories/public'); - await this.page.waitForLoadState('networkidle'); - - // Extract article information - const articles = await this.page.evaluate(() => { - const articleElements = document.querySelectorAll('[data-testid="story-preview"]'); - const articles: MediumArticle[] = []; - - articleElements.forEach(element => { - try { - const titleElement = element.querySelector('h3, h2, [data-testid="story-title"]'); - const linkElement = element.querySelector('a[href*="/"]'); - const dateElement = element.querySelector('[data-testid="story-publish-date"], time'); - - if (titleElement && linkElement) { - articles.push({ - title: titleElement.textContent?.trim() || '', - content: '', // We'll need to fetch full content separately - url: (linkElement as HTMLAnchorElement).href, - publishDate: dateElement?.textContent?.trim() || '', - tags: [] - }); - } - } catch (error) { - console.error('Error extracting article:', error); - } - }); - return articles; + await this.page.goto(this.PUBLISHED_URL, { waitUntil: 'domcontentloaded' }); + await this.waitForChallenge(); + // Give the story list time to render. + await this.page.waitForLoadState('networkidle').catch(() => {}); + await this.page.waitForTimeout(1500); + + const articles = await this.page.evaluate(() => { + const results: { title: string; content: string; url: string; publishDate: string; tags: string[] }[] = []; + const seen = new Set(); + + const anchors = Array.from(document.querySelectorAll('a')) as HTMLAnchorElement[]; + for (const a of anchors) { + const url = (a.href || '').split('?')[0]; + const text = (a.textContent || '').trim(); + // A published story link looks like /@user/slug- or /pub/slug- + const isStory = /\/[^/]+\/.+-[0-9a-f]{6,}$/.test(url) || /\/p\/[0-9a-f]+$/.test(url); + if (!isStory || text.length < 8) continue; + if (seen.has(url)) continue; + seen.add(url); + results.push({ title: text, content: '', url, publishDate: '', tags: [] }); + } + return results; }); + await this.persistState(); return articles; } + /** Fetch the full text of a single article URL. */ async getArticleContent(url: string, requireLogin: boolean = true): Promise { - if (!this.page) throw new Error('Browser not initialized'); - - console.error(`📖 Fetching article content from: ${url}`); - - // Check if we have a saved session first - let isLoggedIn = false; - if (existsSync(this.sessionPath)) { - console.error('💾 Found saved session, checking if still valid...'); - - // Quick check: try to access Medium homepage and look for login indicators - try { - await this.page.goto('https://medium.com'); - await this.page.waitForLoadState('networkidle'); - - // Try to find login indicators quickly - const loginIndicators = [ - '[data-testid="headerUserButton"]', - '[data-testid="write-button"]', - 'a[href="/me/stories"]' - ]; - - for (const selector of loginIndicators) { - try { - await this.page.waitForSelector(selector, { timeout: 2000 }); - console.error('✅ Session is still valid, user is logged in'); - isLoggedIn = true; - break; - } catch { - // Try next selector - } - } - } catch (error) { - console.error('⚠️ Could not verify session validity'); + if (requireLogin) { + if (!(await this.ensureLoggedIn())) { + throw new Error('Medium login was not completed (timed out or cancelled).'); } - } - - if (!isLoggedIn && requireLogin) { - console.error('🔐 Not logged in. Attempting login for full content access...'); - isLoggedIn = await this.ensureLoggedIn(); - } else if (!isLoggedIn && !requireLogin) { - console.error('🔓 Skipping login as requested. Will get preview content only.'); - } - - if (!isLoggedIn) { - console.error('⚠️ Warning: Login failed or skipped. You may only get partial content (preview).'); } else { - console.error('✅ Ready to fetch full article content with login session'); + await this.ensureBrowser(true); } - - try { - console.error(`🌐 Navigating to article: ${url}`); - await this.page.goto(url, { waitUntil: 'networkidle' }); - - // Wait a bit more for dynamic content - await this.page.waitForTimeout(3000); - - console.error('📄 Page loaded, extracting content...'); - - // Extract article content with multiple strategies - const content = await this.page.evaluate(() => { - const log = (...args: any[]) => { - // Silent in browser context to avoid JSON interference - }; - - log('🔍 Starting content extraction...'); - - // Strategy 1: Try modern Medium article selectors - const modernSelectors = [ - 'article section p', - 'article div[data-testid="story-content"] p', - '[data-testid="story-content"] p', - 'article section div p', - 'article p' - ]; - - // Strategy 2: Try classic Medium selectors - const classicSelectors = [ - '.postArticle-content p', - '.section-content p', - '.graf--p', - '.postArticle p' - ]; - - // Strategy 3: Generic content selectors - const genericSelectors = [ - 'main p', - '[role="main"] p', - '.story p', - '.post p' - ]; - - const allSelectors = [...modernSelectors, ...classicSelectors, ...genericSelectors]; - let extractedContent = ''; - - // Try each selector strategy - for (const selector of allSelectors) { - const elements = document.querySelectorAll(selector); - log(`🎯 Selector "${selector}" found ${elements.length} paragraphs`); - - if (elements.length > 3) { // Need at least a few paragraphs for meaningful content - const paragraphs: string[] = []; - - elements.forEach((element, index) => { - const text = element.textContent?.trim(); - if (text && text.length > 20) { // Filter out very short paragraphs - paragraphs.push(text); - } - }); - - if (paragraphs.length > 2) { // Need meaningful content - extractedContent = paragraphs.join('\n\n'); - log(`✅ Successfully extracted ${paragraphs.length} paragraphs using: ${selector}`); - break; - } - } - } - - // Fallback: Try to get any substantial text content - if (!extractedContent) { - log('🔄 Trying fallback content extraction...'); - - const fallbackSelectors = [ - 'article', - 'main', - '[role="main"]', - '.story', - '.post' - ]; - - for (const selector of fallbackSelectors) { - const element = document.querySelector(selector); - if (element) { - const text = element.textContent?.trim(); - if (text && text.length > 200) { - // Clean up the text a bit - extractedContent = text - .replace(/\s+/g, ' ') // Normalize whitespace - .replace(/(.{100})/g, '$1\n\n') // Add paragraph breaks - .substring(0, 5000); // Limit length - - log(`✅ Fallback extraction successful using: ${selector}`); - break; - } - } - } - } - - // Debug info if still no content - if (!extractedContent) { - log('❌ No content found. Page analysis:'); - log('Page title:', document.title); - log('Page URL:', window.location.href); - log('Body text length:', document.body.textContent?.length || 0); - - // Check if we hit a paywall or login requirement - const paywallIndicators = [ - 'sign up', - 'subscribe', - 'member-only', - 'paywall', - 'premium', - 'upgrade' - ]; - - const pageText = document.body.textContent?.toLowerCase() || ''; - const foundIndicators = paywallIndicators.filter(indicator => - pageText.includes(indicator) - ); - - if (foundIndicators.length > 0) { - log('🚫 Possible paywall detected:', foundIndicators); - return `Content may be behind a paywall or require login. Found indicators: ${foundIndicators.join(', ')}`; - } - - return 'Unable to extract article content. The article may be behind a paywall, require login, or use an unsupported layout.'; - } - - // Check if we might be getting only a preview (very short content) - if (extractedContent.length < 500) { - log('⚠️ Warning: Content seems short, might be preview only'); - - // Look for "continue reading" or member-only indicators - const previewIndicators = [ - 'continue reading', - 'read more', - 'member-only story', - 'this story is for members only', - 'become a member', - 'sign up to continue', - 'subscribe to read' - ]; - - const pageText = document.body.textContent?.toLowerCase() || ''; - const foundPreviewIndicators = previewIndicators.filter(indicator => - pageText.includes(indicator) - ); - - if (foundPreviewIndicators.length > 0) { - log('🔒 Preview-only content detected:', foundPreviewIndicators); - extractedContent = `[PREVIEW ONLY - Login required for full content]\n\n${extractedContent}\n\n[This appears to be only a preview. The full article requires Medium membership or login. Found indicators: ${foundPreviewIndicators.join(', ')}]`; - } + if (!this.page) throw new Error('Browser not initialized'); + + console.error(`📖 Fetching article content from: ${url}`); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + await this.waitForChallenge(); + await this.page.waitForLoadState('networkidle').catch(() => {}); + await this.page.waitForTimeout(1500); + + const content = await this.page.evaluate(() => { + const selectors = [ + 'article section p', + '[data-testid="story-content"] p', + 'article p', + 'main p', + '[role="main"] p', + ]; + for (const selector of selectors) { + const els = document.querySelectorAll(selector); + if (els.length > 3) { + const paragraphs: string[] = []; + els.forEach(el => { + const text = el.textContent?.trim(); + if (text && text.length > 20) paragraphs.push(text); + }); + if (paragraphs.length > 2) return paragraphs.join('\n\n'); } - - log(`📊 Final content length: ${extractedContent.length} characters`); - return extractedContent; - }); - - console.error(`✅ Content extraction completed. Length: ${content.length} characters`); - return content; - - } catch (error) { - console.error('❌ Error fetching article content:', error); - throw new Error(`Failed to fetch article content: ${error}`); - } + } + for (const selector of ['article', 'main', '[role="main"]']) { + const el = document.querySelector(selector); + const text = el?.textContent?.trim(); + if (text && text.length > 200) return text.replace(/\s+/g, ' ').substring(0, 8000); + } + const pageText = document.body.textContent?.toLowerCase() || ''; + if (/member-only|become a member|sign up to continue|subscribe to read/.test(pageText)) { + return 'Content appears to be behind a paywall / member-only.'; + } + return 'Unable to extract article content (unsupported layout or paywall).'; + }); + + console.error(`✅ Content extracted (${content.length} chars).`); + await this.persistState(); + return content; } + /** Publish (or draft) a new article via the Medium editor. */ async publishArticle(options: PublishOptions): Promise<{ success: boolean; url?: string; error?: string }> { + if (!(await this.ensureLoggedIn())) { + return { success: false, error: 'Medium login was not completed (timed out or cancelled).' }; + } if (!this.page) throw new Error('Browser not initialized'); - - await this.ensureLoggedIn(); try { - // Navigate to the new story page - await this.page.goto('https://medium.com/new-story'); - await this.page.waitForLoadState('networkidle'); + await this.page.goto('https://medium.com/new-story', { waitUntil: 'domcontentloaded' }); + await this.waitForChallenge(); + await this.page.waitForLoadState('networkidle').catch(() => {}); - // Wait for the editor to load - await this.page.waitForSelector('[data-testid="richTextEditor"]', { timeout: 10000 }); + await this.page.waitForSelector('[data-testid="richTextEditor"]', { timeout: 15000 }); - // Add title const titleSelector = '[data-testid="richTextEditor"] h1, [placeholder*="Title"], .graf--title'; await this.page.waitForSelector(titleSelector); await this.page.click(titleSelector); await this.page.fill(titleSelector, options.title); - // Add content const contentSelector = '[data-testid="richTextEditor"] p, .graf--p'; await this.page.waitForSelector(contentSelector); await this.page.click(contentSelector); - - // Split content into paragraphs and add them + const paragraphs = options.content.split('\n\n').filter(p => p.trim()); for (let i = 0; i < paragraphs.length; i++) { if (i > 0) { @@ -455,16 +332,11 @@ export class BrowserMediumClient { await this.page.keyboard.type(paragraphs[i]); } - // Add tags if provided if (options.tags && options.tags.length > 0) { - // Look for publish button to access settings - const publishButton = await this.page.locator('button:has-text("Publish"), [data-testid="publish-button"]').first(); + const publishButton = this.page.locator('button:has-text("Publish"), [data-testid="publish-button"]').first(); if (await publishButton.isVisible()) { await publishButton.click(); - - // Wait for publish modal and add tags await this.page.waitForSelector('[data-testid="tag-input"], input[placeholder*="tag"]', { timeout: 5000 }); - for (const tag of options.tags) { await this.page.fill('[data-testid="tag-input"], input[placeholder*="tag"]', tag); await this.page.keyboard.press('Enter'); @@ -473,284 +345,77 @@ export class BrowserMediumClient { } if (options.isDraft) { - // Save as draft - const saveButton = await this.page.locator('button:has-text("Save draft"), [data-testid="save-draft"]').first(); - if (await saveButton.isVisible()) { - await saveButton.click(); - } + const saveButton = this.page.locator('button:has-text("Save draft"), [data-testid="save-draft"]').first(); + if (await saveButton.isVisible()) await saveButton.click(); + await this.persistState(); return { success: true }; - } else { - // Publish the article - const finalPublishButton = await this.page.locator('button:has-text("Publish now"), [data-testid="publish-now"]').first(); - if (await finalPublishButton.isVisible()) { - await finalPublishButton.click(); - - // Wait for success and get URL - await this.page.waitForLoadState('networkidle'); - const currentUrl = this.page.url(); - - return { success: true, url: currentUrl }; - } } - return { success: false, error: 'Could not find publish button' }; + const finalPublishButton = this.page + .locator('button:has-text("Publish now"), [data-testid="publish-now"]') + .first(); + if (await finalPublishButton.isVisible()) { + await finalPublishButton.click(); + await this.page.waitForLoadState('networkidle').catch(() => {}); + await this.persistState(); + return { success: true, url: this.page.url() }; + } + return { success: false, error: 'Could not find publish button' }; } catch (error) { return { success: false, error: `Publishing failed: ${error}` }; } } + /** Search Medium (no login required). */ async searchMediumArticles(keywords: string[]): Promise { + await this.ensureBrowser(true); if (!this.page) throw new Error('Browser not initialized'); - + const searchQuery = keywords.join(' '); console.error(`🔍 Searching Medium for: "${searchQuery}"`); - - // Try to use saved session if available (but don't force login for search) - if (existsSync(this.sessionPath)) { - console.error('💾 Using saved session for search...'); - } - - await this.page.goto(`https://medium.com/search?q=${encodeURIComponent(searchQuery)}`); - await this.page.waitForLoadState('networkidle'); - - // Wait a bit more for dynamic content to load - await this.page.waitForTimeout(2000); - - console.error('📄 Current page URL:', this.page.url()); - - const articles = await this.page.evaluate((searchQuery) => { - // Remove console.log from browser context to avoid JSON interference - const log = (...args: any[]) => { - // Silent in browser context - }; - - log('🔎 Starting search extraction for:', searchQuery); - - // Try multiple selectors for different Medium layouts - const possibleSelectors = [ - // Modern Medium selectors - 'article', - '[data-testid="story-preview"]', - '[data-testid="story-card"]', - '.js-postListItem', - '.postArticle', - '.streamItem', - '.js-streamItem', - // Fallback selectors - 'div[role="article"]', - '.story-preview', - '.post-preview' - ]; - const articles: any[] = []; - let elementsFound = 0; - - for (const selector of possibleSelectors) { - const elements = document.querySelectorAll(selector); - log(`🎯 Selector "${selector}" found ${elements.length} elements`); - - if (elements.length > 0) { - elementsFound += elements.length; - - elements.forEach((element, index) => { - try { - // Try multiple title selectors - const titleSelectors = [ - 'h1', 'h2', 'h3', 'h4', - '[data-testid="story-title"]', - '.graf--title', - '.story-title', - '.post-title', - 'a[data-action="show-post"]' - ]; - - let titleElement = null; - let titleText = ''; - - for (const titleSel of titleSelectors) { - titleElement = element.querySelector(titleSel); - if (titleElement && titleElement.textContent?.trim()) { - titleText = titleElement.textContent.trim(); - break; - } - } - - // Try multiple approaches to find the actual article URL - let linkUrl = ''; - - // Strategy 1: Look for data-href attribute (most reliable for articles) - const dataHrefElement = element.querySelector('[data-href]'); - if (dataHrefElement) { - const dataHref = dataHrefElement.getAttribute('data-href'); - if (dataHref && dataHref.includes('medium.com') && dataHref.includes('-')) { - linkUrl = dataHref; - } - } - - // Strategy 2: Look for direct article links if data-href didn't work - if (!linkUrl) { - const linkSelectors = [ - 'a[href*="medium.com"][href*="-"]', // Article URLs usually have dashes - 'a[href*="/@"][href*="-"]', // Author articles with dashes - 'a[href*="medium.com"]', - 'a[href*="/"]', - 'a' - ]; - - for (const linkSel of linkSelectors) { - const linkElement = element.querySelector(linkSel); - if (linkElement && (linkElement as HTMLAnchorElement).href) { - let href = (linkElement as HTMLAnchorElement).href; - - // Clean up and validate the URL - if (href) { - // If it's a redirect URL, extract the actual article URL - if (href.includes('redirect=')) { - const redirectMatch = href.match(/redirect=([^&]+)/); - if (redirectMatch) { - href = decodeURIComponent(redirectMatch[1]); - } - } - - // Check if it's a valid article URL (prioritize actual articles) - const isValidArticleUrl = ( - href.includes('medium.com') && - !href.includes('/search?') && // Don't include search pages themselves - !href.includes('/signin') && - !href.includes('/bookmark') && - !href.includes('/signup') && - // Prioritize URLs that look like actual articles - (href.includes('-') || // Article slugs usually have dashes - href.includes('/@') || - href.match(/\/[a-f0-9]{8,}/)) // Article IDs (8+ chars) - ); - - if (isValidArticleUrl) { - // Clean the URL but preserve the path - if (href.includes('?')) { - // Extract the actual article URL from redirect parameters - if (href.includes('redirect=')) { - const redirectMatch = href.match(/redirect=([^&]+)/); - if (redirectMatch) { - linkUrl = decodeURIComponent(redirectMatch[1]); - } - } else { - // Just remove query parameters for cleaner URLs - linkUrl = href.split('?')[0]; - } - } else { - linkUrl = href; - } - break; - } - } - } - } - } - - // Try to get author info - const authorSelectors = [ - '[data-testid="story-author"]', - '.postMetaInline-authorLockup', - '.story-author', - '.author-name' - ]; - - let authorText = ''; - for (const authorSel of authorSelectors) { - const authorElement = element.querySelector(authorSel); - if (authorElement && authorElement.textContent?.trim()) { - authorText = authorElement.textContent.trim(); - break; - } - } - - // Try to get snippet/preview - const snippetSelectors = [ - '.story-excerpt', - '.post-excerpt', - '.graf--p', - 'p' - ]; - - let snippetText = ''; - for (const snippetSel of snippetSelectors) { - const snippetElement = element.querySelector(snippetSel); - if (snippetElement && snippetElement.textContent?.trim()) { - snippetText = snippetElement.textContent.trim().substring(0, 200); - break; - } - } - - log(`📝 Article ${index + 1}:`, { - title: titleText, - url: linkUrl, - author: authorText, - snippet: snippetText.substring(0, 50) + '...' - }); - - if (titleText && linkUrl) { - articles.push({ - title: titleText, - content: snippetText, - url: linkUrl, - publishDate: '', - tags: [], - claps: 0 - }); - } - } catch (error) { - log('❌ Error extracting article:', error); - } - }); + await this.page.goto(`https://medium.com/search?q=${encodeURIComponent(searchQuery)}`, { + waitUntil: 'domcontentloaded', + }); + await this.waitForChallenge(); + await this.page.waitForLoadState('networkidle').catch(() => {}); + await this.page.waitForTimeout(1500); - // If we found articles with this selector, we can break - if (articles.length > 0) { - log(`✅ Successfully extracted ${articles.length} articles using selector: ${selector}`); - break; - } + const articles = await this.page.evaluate(() => { + const results: any[] = []; + const seen = new Set(); + const anchors = Array.from(document.querySelectorAll('a')) as HTMLAnchorElement[]; + + for (const a of anchors) { + let href = a.href || ''; + const text = (a.textContent || '').trim(); + if (href.includes('redirect=')) { + const m = href.match(/redirect=([^&]+)/); + if (m) href = decodeURIComponent(m[1]); } - } + href = href.split('?')[0]; - log(`📊 Total elements found: ${elementsFound}, Articles extracted: ${articles.length}`); - - // If no articles found, let's debug what's on the page - if (articles.length === 0) { - log('🔍 Debug: Page structure analysis'); - log('Page title:', document.title); - log('Page text content preview:', document.body.textContent?.substring(0, 500)); - - // Look for any text that might indicate search results - const searchResultIndicators = [ - 'No stories found', - 'No results', - 'Try different keywords', - 'stories found', - 'results for' - ]; - - const pageText = document.body.textContent?.toLowerCase() || ''; - for (const indicator of searchResultIndicators) { - if (pageText.includes(indicator.toLowerCase())) { - log(`📍 Found indicator: "${indicator}"`); - } + const looksLikeArticle = /\/[^/]+\/.+-[0-9a-f]{6,}$/.test(href) || /\/p\/[0-9a-f]+$/.test(href); + const isJunk = /\/(signin|signup|search|bookmark|tag|me)\b/.test(href); + if (looksLikeArticle && !isJunk && text.length > 12 && !seen.has(href)) { + seen.add(href); + results.push({ title: text, content: '', url: href, publishDate: '', tags: [], claps: 0 }); } } + return results; + }); - return articles; - }, searchQuery); - - console.error(`🎉 Search completed. Found ${articles.length} articles`); + console.error(`🎉 Search completed. Found ${articles.length} articles.`); return articles; } async close(): Promise { - if (this.browser) { - await this.browser.close(); - this.browser = null; + if (this.context) { + await this.context.close().catch(() => {}); this.context = null; this.page = null; + this.currentHeadless = null; } } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 6c44071..b98a084 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,7 @@ class MediumMcpServer { // Tool for publishing articles (now browser-based) this.server.tool( "publish-article", - "Publish a new article on Medium using browser automation", + "Publish a new article on Medium using browser automation. On the first use (no saved session) a one-time login window may open; after that the session is reused silently in the background.", { title: z.string().min(1, "Title is required"), content: z.string().min(10, "Content must be at least 10 characters"), @@ -69,7 +69,7 @@ class MediumMcpServer { // Tool for retrieving user's published articles this.server.tool( "get-my-articles", - "Retrieve your published Medium articles", + "Retrieve your published Medium articles. On the first use (no saved session) a one-time login window may open; after that the session is reused silently in the background.", {}, async () => { try { @@ -100,10 +100,10 @@ class MediumMcpServer { // Tool for getting full content of a specific article this.server.tool( "get-article-content", - "Get the full content of a Medium article by URL", + "Get the full content of a Medium article by URL. When requireLogin is true and there is no saved session, a one-time login window may open on first use; after that the session is reused silently in the background.", { url: z.string().url("Must be a valid URL"), - requireLogin: z.boolean().optional().default(true).describe("Whether to attempt login for full content access") + requireLogin: z.boolean().optional().default(true).describe("If true, ensures you are logged in (may open a one-time login window on first use) to access full/member-only content. Set false for preview-only content without login.") }, async (args) => { try { @@ -199,9 +199,10 @@ class MediumMcpServer { // Method to start the server async start() { try { - // Initialize browser client + // Browser is launched lazily (headless) on first tool use — no window + // appears at startup. A visible window only opens for one-time login. await this.mediumClient.initialize(); - console.error("🌐 Browser Medium client initialized"); + console.error("🌐 Browser Medium client ready (lazy, headless)"); const transport = new StdioServerTransport(); await this.server.connect(transport);