Skip to content

Commit 32a6f84

Browse files
childrentimeclaude
andcommitted
feat(medium-push): implement Medium Push extension for Markdown to Medium editor
- Add a Chrome extension that allows users to push local Markdown files to Medium's editor as rich text. - Implement a bridge server for handling content transfer and communication between the extension and local files. - Include a user-friendly popup for content input and file selection. - Support for Markdown features such as headings, lists, images, and code blocks with zero-width space handling to prevent formatting issues in Medium. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ba6d9e9 commit 32a6f84

8 files changed

Lines changed: 1018 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
---
2+
name: medium-push
3+
category: publishing
4+
description: 将 Markdown 文件推送到浏览器中打开的 Medium 编辑器。当用户说"发布到 medium"、"推送到 medium"、"paste to medium"、"push to medium"或在完成文章生成后要求发送到 Medium 时触发。
5+
---
6+
7+
# medium-push - 推送 Markdown 到 Medium 编辑器
8+
9+
通过 Chrome 扩展 + Bridge 服务(HTTP + SSE),将本地 Markdown 文件转换为富文本并推送到浏览器中打开的 Medium 编辑器。
10+
11+
## 架构
12+
13+
```
14+
Claude Code (读取 Markdown 文件)
15+
→ curl POST http://localhost:18766/paste
16+
→ Bridge Server (SSE 推送)
17+
→ Chrome Extension (content script)
18+
→ Markdown → HTML 转换
19+
→ Medium 编辑器 (模拟粘贴富文本)
20+
```
21+
22+
## 工作流程
23+
24+
### 步骤 1:检查 Bridge 服务
25+
26+
```bash
27+
curl -s http://localhost:18766/health 2>/dev/null || echo "NOT_RUNNING"
28+
```
29+
30+
| 结果 | 操作 |
31+
|------|------|
32+
| `{"status":"ok",...}` | Bridge 已运行,跳到步骤 2 |
33+
| `NOT_RUNNING` | 启动 Bridge 服务 |
34+
35+
**启动 Bridge:**
36+
37+
```bash
38+
nohup node <skill-path>/scripts/bridge.mjs > /tmp/medium-push-bridge.log 2>&1 &
39+
echo $!
40+
```
41+
42+
等待 1 秒后再次检查 `/health` 确认启动成功。
43+
44+
### 步骤 2:确认 Medium 编辑器已打开
45+
46+
提示用户在 Chrome 中打开 Medium 新文章页面:
47+
48+
```
49+
https://medium.com/new-story
50+
```
51+
52+
### 步骤 3:读取 Markdown 文件
53+
54+
读取用户指定的 Markdown 文件路径。如果用户没有指定,查找 `blog-external/` 目录下的 `medium.md` 文件。
55+
56+
### 步骤 4:发送到 Medium 编辑器
57+
58+
**方式 A:通过文件路径(推荐)**
59+
60+
```bash
61+
curl -s -X POST http://localhost:18766/paste \
62+
-H "Content-Type: application/json" \
63+
-d '{"filePath": "<absolute-path-to-markdown-file>"}'
64+
```
65+
66+
**方式 B:直接发送内容**
67+
68+
```bash
69+
curl -s -X POST http://localhost:18766/paste \
70+
-H "Content-Type: application/json" \
71+
-d @- <<'PAYLOAD'
72+
{"content": "<markdown-content-here>"}
73+
PAYLOAD
74+
```
75+
76+
### 步骤 5:确认结果
77+
78+
检查响应中的字段:
79+
80+
| 字段 | 含义 |
81+
|------|------|
82+
| `success: true` | 请求成功 |
83+
| `clientsSent > 0` | 内容已推送到浏览器扩展 |
84+
| `clientsSent = 0` | 没有连接的扩展客户端 |
85+
86+
如果 `clientsSent = 0`,提示用户:
87+
1. 确认 Chrome 扩展已安装
88+
2. 确认已打开 `https://medium.com/new-story` 页面
89+
3. 刷新页面后重试
90+
91+
**输出格式:**
92+
```
93+
文章已推送到 Medium 编辑器!
94+
95+
文件:{file_path}
96+
内容长度:{contentLength} 字符
97+
浏览器客户端:{clientsSent} 个
98+
99+
请在浏览器中查看 Medium 编辑器并检查内容格式。
100+
```
101+
102+
## 首次安装
103+
104+
### 1. 安装 Chrome 扩展
105+
106+
1. 打开 Chrome,访问 `chrome://extensions/`
107+
2. 开启右上角 **开发者模式**
108+
3. 点击 **加载已解压的扩展程序**
109+
4. 选择 `<skill-path>/extension/` 目录
110+
5. 安装完成
111+
112+
### 2. Bridge 服务
113+
114+
Bridge 为纯 Node.js 实现,零外部依赖,无需 npm install。
115+
默认端口 18766(避免与 md-push 的 18765 冲突),可通过 `BRIDGE_PORT` 环境变量修改。
116+
117+
## 注意事项
118+
119+
- Bridge 使用 SSE(Server-Sent Events)推送,扩展会自动重连
120+
- 扩展会自动剥离 Markdown 的 YAML frontmatter
121+
- Markdown 在扩展端转换为 HTML 后通过模拟粘贴注入 Medium 编辑器
122+
- 第一个 `#` 标题会自动填入 Medium 的标题字段
123+
- 支持:标题、段落、粗体、斜体、代码块、行内代码、链接、图片、列表、引用
124+
- 扩展 popup 支持手动粘贴(无需 Bridge)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Medium Push Content Script (ISOLATED world)
3+
*
4+
* Connects to the bridge server via SSE and forwards
5+
* converted HTML content to main-world.js via window.postMessage.
6+
*/
7+
8+
const BRIDGE_URL = 'http://localhost:18766';
9+
let eventSource = null;
10+
let reconnectTimer = null;
11+
let reconnectDelay = 1000;
12+
13+
function connectBridge() {
14+
if (eventSource) {
15+
eventSource.close();
16+
eventSource = null;
17+
}
18+
19+
try {
20+
eventSource = new EventSource(`${BRIDGE_URL}/events`);
21+
22+
eventSource.onopen = () => {
23+
console.log('[MediumPush] Connected to bridge server');
24+
reconnectDelay = 1000;
25+
};
26+
27+
eventSource.onmessage = (event) => {
28+
try {
29+
const data = JSON.parse(event.data);
30+
if (data.type === 'SET_MARKDOWN' && data.content) {
31+
processAndSend(data.content);
32+
} else if (data.type === 'INSPECT_DOM') {
33+
window.postMessage({ type: 'MEDIUM_PUSH_INSPECT' }, '*');
34+
}
35+
} catch (e) {
36+
console.error('[MediumPush] Failed to parse SSE message:', e);
37+
}
38+
};
39+
40+
eventSource.onerror = () => {
41+
eventSource.close();
42+
eventSource = null;
43+
scheduleReconnect();
44+
};
45+
} catch (e) {
46+
console.error('[MediumPush] EventSource error:', e);
47+
scheduleReconnect();
48+
}
49+
}
50+
51+
function scheduleReconnect() {
52+
if (reconnectTimer) clearTimeout(reconnectTimer);
53+
reconnectTimer = setTimeout(() => {
54+
connectBridge();
55+
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
56+
}, reconnectDelay);
57+
}
58+
59+
/**
60+
* Process markdown: strip frontmatter, extract title, convert to HTML
61+
* (with zero-width spaces in code block empty lines), then forward.
62+
*/
63+
function processAndSend(markdown) {
64+
const { stripFrontmatter, extractTitle, markdownToHtml } = window.__mdToHtml;
65+
66+
const cleaned = stripFrontmatter(markdown);
67+
const { title, body } = extractTitle(cleaned);
68+
const html = markdownToHtml(body);
69+
70+
console.log('[MediumPush] Converted markdown to HTML', {
71+
titleLength: title.length,
72+
htmlLength: html.length,
73+
});
74+
75+
window.postMessage({
76+
type: 'MEDIUM_PUSH_SET_CONTENT',
77+
title,
78+
html,
79+
}, '*');
80+
}
81+
82+
// Listen for messages from popup
83+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
84+
if (msg.type === 'SET_MARKDOWN' && msg.content) {
85+
processAndSend(msg.content);
86+
sendResponse({ success: true });
87+
} else if (msg.type === 'GET_STATUS') {
88+
sendResponse({
89+
bridgeConnected: eventSource !== null && eventSource.readyState === EventSource.OPEN,
90+
onMedium: window.location.hostname === 'medium.com',
91+
});
92+
}
93+
return true;
94+
});
95+
96+
// Listen for results from main-world.js
97+
window.addEventListener('message', (event) => {
98+
if (event.source !== window) return;
99+
if (event.data && event.data.type === 'MEDIUM_PUSH_RESULT') {
100+
if (event.data.success) {
101+
console.log('[MediumPush] Content set successfully via', event.data.method);
102+
} else {
103+
console.error('[MediumPush] Failed to set content:', event.data.error);
104+
}
105+
}
106+
if (event.data && event.data.type === 'MEDIUM_PUSH_INSPECT_RESULT') {
107+
fetch(`${BRIDGE_URL}/inspect-result`, {
108+
method: 'POST',
109+
headers: { 'Content-Type': 'application/json' },
110+
body: JSON.stringify(event.data.result),
111+
}).catch(e => console.error('[MediumPush] Failed to send inspect result:', e));
112+
}
113+
});
114+
115+
// Only connect on Medium
116+
if (window.location.hostname === 'medium.com') {
117+
connectBridge();
118+
console.log('[MediumPush] Content script loaded on', window.location.href);
119+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* Medium Push Main World Script
3+
*
4+
* Injects HTML content into Medium's editor via paste simulation.
5+
* Code blocks use zero-width spaces on empty lines to prevent
6+
* Medium from splitting them at blank lines.
7+
*/
8+
9+
window.addEventListener('message', async (event) => {
10+
if (event.source !== window) return;
11+
if (!event.data) return;
12+
13+
if (event.data.type === 'MEDIUM_PUSH_INSPECT') {
14+
window.postMessage({ type: 'MEDIUM_PUSH_INSPECT_RESULT', result: inspectDOM() }, '*');
15+
return;
16+
}
17+
18+
if (event.data.type !== 'MEDIUM_PUSH_SET_CONTENT') return;
19+
20+
const { title, html } = event.data;
21+
console.log('[MediumPush] Received content, title:', title?.slice(0, 50), 'html length:', html?.length);
22+
23+
try {
24+
const editor = findEditor();
25+
if (!editor) {
26+
reportResult(false, 'none', 'Could not find Medium editor');
27+
return;
28+
}
29+
30+
editor.focus();
31+
await sleep(200);
32+
33+
const fullHtml = title ? `<h3>${escapeHtml(title)}</h3>${html}` : html;
34+
35+
// Try paste with proper clipboardData
36+
const success = await tryPaste(editor, fullHtml);
37+
if (success) {
38+
reportResult(true, 'paste', null);
39+
return;
40+
}
41+
42+
// Fallback: insertHTML
43+
const insertOk = tryInsertHtml(editor, fullHtml);
44+
if (insertOk) {
45+
reportResult(true, 'insertHTML', null);
46+
return;
47+
}
48+
49+
// Last resort: direct DOM
50+
editor.innerHTML = fullHtml;
51+
triggerInput(editor);
52+
reportResult(true, 'innerHTML', null);
53+
} catch (e) {
54+
reportResult(false, 'none', e.message);
55+
}
56+
});
57+
58+
async function tryPaste(editor, html) {
59+
editor.focus();
60+
const sel = window.getSelection();
61+
const range = document.createRange();
62+
range.selectNodeContents(editor);
63+
sel.removeAllRanges();
64+
sel.addRange(range);
65+
await sleep(100);
66+
67+
const plainText = htmlToPlainText(html);
68+
const dt = new DataTransfer();
69+
dt.setData('text/html', html);
70+
dt.setData('text/plain', plainText);
71+
72+
const evt = new ClipboardEvent('paste', { bubbles: true, cancelable: true });
73+
Object.defineProperty(evt, 'clipboardData', { value: dt, writable: false });
74+
75+
editor.dispatchEvent(evt);
76+
await sleep(500);
77+
78+
const newText = editor.textContent || '';
79+
return newText.length > 30 && !newText.includes('Tell your story');
80+
}
81+
82+
function tryInsertHtml(editor, html) {
83+
editor.focus();
84+
const sel = window.getSelection();
85+
const range = document.createRange();
86+
range.selectNodeContents(editor);
87+
sel.removeAllRanges();
88+
sel.addRange(range);
89+
90+
document.execCommand('delete', false);
91+
const ok = document.execCommand('insertHTML', false, html);
92+
if (ok) triggerInput(editor);
93+
return ok;
94+
}
95+
96+
function findEditor() {
97+
return (
98+
document.querySelector('.postArticle-content[role="textbox"][contenteditable="true"]') ||
99+
document.querySelector('[role="textbox"][contenteditable="true"]') ||
100+
document.querySelector('.editable[contenteditable="true"]') ||
101+
document.querySelector('article [contenteditable="true"]')
102+
);
103+
}
104+
105+
function htmlToPlainText(html) {
106+
const div = document.createElement('div');
107+
div.innerHTML = html;
108+
return div.textContent || div.innerText || '';
109+
}
110+
111+
function escapeHtml(text) {
112+
const div = document.createElement('div');
113+
div.textContent = text;
114+
return div.innerHTML;
115+
}
116+
117+
function triggerInput(el) {
118+
el.dispatchEvent(new Event('input', { bubbles: true }));
119+
}
120+
121+
function reportResult(success, method, error) {
122+
console.log('[MediumPush] Result:', { success, method, error });
123+
window.postMessage({ type: 'MEDIUM_PUSH_RESULT', success, method, error }, '*');
124+
}
125+
126+
function sleep(ms) {
127+
return new Promise(r => setTimeout(r, ms));
128+
}
129+
130+
function inspectDOM() {
131+
function desc(el) {
132+
const r = el.getBoundingClientRect();
133+
return {
134+
tag: el.tagName.toLowerCase(), id: el.id || null,
135+
className: (el.className || '').toString().slice(0, 120),
136+
role: el.getAttribute('role'), contentEditable: el.getAttribute('contenteditable'),
137+
dataTestId: el.getAttribute('data-testid'), dataPlaceholder: el.getAttribute('data-placeholder'),
138+
textContent: (el.textContent || '').slice(0, 60), childCount: el.children.length,
139+
rect: { w: Math.round(r.width), h: Math.round(r.height), t: Math.round(r.top), l: Math.round(r.left) },
140+
parentTag: el.parentElement?.tagName.toLowerCase(), parentClass: (el.parentElement?.className || '').toString().slice(0, 80),
141+
};
142+
}
143+
return {
144+
url: window.location.href,
145+
editables: [...document.querySelectorAll('[contenteditable]')].map(desc),
146+
roles: [...document.querySelectorAll('[role="textbox"]')].map(desc),
147+
};
148+
}
149+
150+
console.log('[MediumPush] Main world script loaded (paste + zero-width-space mode)');

0 commit comments

Comments
 (0)