From c238267b1dff5c4a64ce6d1107045c60e9c5c96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20G=C3=BCler?= <22560050+emregulerr@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:05:39 +0300 Subject: [PATCH 1/2] Fix session persistence, Cloudflare challenge, and empty get-my-articles The authenticated tools returned empty results and forced repeated logins. Several overlapping root causes (see #1): - Re-inject a full storageState snapshot on every launch so Medium's session cookies (sid/xsrf) survive the headed->headless switch and server restarts. A persistent profile alone drops pure session cookies on reopen. - Use one consistent User-Agent for both the login window and headless runs, and wait out Cloudflare's "Just a moment..." interstitial after navigation. The UA mismatch (Chrome vs HeadlessChrome) was invalidating cf_clearance. - Navigate to the correct published tab (?tab=posts-published) instead of /me/stories/public, which now redirects to Drafts. - Detect login from the actual page (no header Sign-in button) instead of cookie presence, since Medium sets sid/uid for anonymous visitors too. - Run headless in the background by default; the visible window only opens for the one-time login. Auto-login is triggered on demand by the other tools. This also fixes the documented Google-login persistence limitation: a Google login is now captured and reused silently (verified end-to-end). Closes #1 --- src/browser-client.ts | 939 ++++++++++++++---------------------------- src/index.ts | 13 +- 2 files changed, 309 insertions(+), 643 deletions(-) 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); From 7c7569157aced0434f30b867b900d54775ce007b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20G=C3=BCler?= <22560050+emregulerr@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:15:35 +0300 Subject: [PATCH 2/2] Harden .gitignore against committing auth session/profile files Defense-in-depth: explicitly ignore the persistent auth session snapshot and Chrome profile directory (they hold login cookies). They live in the user's home dir today, but this guards against any future path change. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) 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