
Linkedin Article Publisher
- 10 installs
- 19 repo stars
- Updated January 16, 2026
- iamzifei/linkedin-article-publisher-skill
Helps with ai & agent building tasks.
About
linkedin-article-publisher is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- linkedin-article-publisher
- AI & Agent Building
- AI-coding skill
Linkedin Article Publisher by the numbers
- 10 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #11,959 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzifei/linkedin-article-publisher-skill --skill linkedin-article-publisherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 19 |
| Last updated | January 16, 2026 |
| Repository | iamzifei/linkedin-article-publisher-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
LinkedIn Article Publisher
Publish Markdown content to LinkedIn Articles editor, preserving formatting with rich text conversion.
Prerequisites
- Playwright MCP for browser automation
- User logged into LinkedIn in browser
- Python 3.9+ with dependencies:
pip install Pillow pyobjc-framework-Cocoa
Scripts
Located in ~/.claude/skills/linkedin-article-publisher/scripts/:
parse_markdown.py
Parse Markdown and extract structured data:
python parse_markdown.py <markdown_file> [--output json|html] [--html-only]Returns JSON with: title, cover_image, content_images (with block_index for positioning), html, total_blocks
copy_to_clipboard.py
Copy image or HTML to system clipboard:
# Copy image (with optional compression)
python copy_to_clipboard.py image /path/to/image.jpg [--quality 80]
# Copy HTML for rich text paste
python copy_to_clipboard.py html --file /path/to/content.htmlWorkflow
Strategy: "Text First, Images Later"
For articles with multiple images, paste ALL text content first, then insert images at correct positions using block index.
1. Parse Markdown with Python script -> get title, images with block_index, HTML 2. Navigate to LinkedIn Articles editor 3. Upload cover image (first image) 4. Fill title 5. Copy HTML to clipboard (Python) -> Paste with Cmd+V 6. Insert content images at positions specified by block_index 7. Save as draft (NEVER auto-publish)
Efficiency Guidelines
Goal: Minimize wait time between operations for smooth automation.
1. Avoid unnecessary browser_snapshot
Most browser operations (click, type, press_key, etc.) return page state in response. Don't call browser_snapshot after every operation - use the returned state directly.
Bad:
browser_click -> browser_snapshot -> analyze -> browser_click -> browser_snapshot -> ...
Good:
browser_click -> use returned state -> browser_click -> ...2. Avoid unnecessary browser_wait_for
Only use browser_wait_for when:
- Waiting for image upload to complete
- Waiting for initial page load (rare cases)
Don't use browser_wait_for for buttons or inputs - they're available immediately after page load.
3. Parallel execution of independent operations
When two operations have no dependencies, call multiple tools in the same message:
Can parallel:
- Fill title (browser_type) + Copy HTML to clipboard (Bash)
- Parse Markdown JSON + Generate HTML file
Cannot parallel (has dependencies):
- Must navigate to editor before uploading cover image
- Must paste content before inserting images4. Chain browser operations continuously
Each browser operation returns page state with element references. Use these references directly for the next operation:
# Ideal flow (execute directly, no extra waits):
browser_navigate -> find cover button in returned state -> browser_click(cover)
-> find title field in returned state -> browser_type(title)
-> click editor -> browser_press_key(Meta+v)
-> ...5. Prepare work upfront
Before starting browser operations, complete all preparation: 1. Parse Markdown to get JSON data 2. Generate HTML file to /tmp/ 3. Record title, cover_image, content_images info
This allows browser operations to execute continuously without stopping for data processing.
Step 1: Parse Markdown (Python)
Use parse_markdown.py to extract all structured data:
python ~/.claude/skills/linkedin-article-publisher/scripts/parse_markdown.py /path/to/article.mdOutput JSON:
{
"title": "Article Title",
"cover_image": "/path/to/first-image.jpg",
"content_images": [
{"path": "/path/to/img2.jpg", "block_index": 5, "after_text": "context for debugging..."},
{"path": "/path/to/img3.jpg", "block_index": 12, "after_text": "another context..."}
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 45
}Key fields:
block_index: The image should be inserted AFTER block element at this index (0-indexed)total_blocks: Total number of block elements in the HTMLafter_text: Kept for reference/debugging only, NOT for positioning
Save HTML to temp file for clipboard:
python parse_markdown.py article.md --html-only > /tmp/article_html.htmlStep 2: Open LinkedIn Articles Editor
browser_navigate: https://www.linkedin.com/article/new/Important: The page loads directly into the article editor. Wait for the editor to fully load:
1. Wait for page load: Use browser_snapshot to check page state 2. Look for editor elements: Title input field, content editor area, cover image button 3. If redirect occurs: LinkedIn may redirect - follow the redirect URL
# 1. Navigate to editor
browser_navigate: https://www.linkedin.com/article/new/
# 2. Get page snapshot to find elements
browser_snapshot
# 3. Proceed with cover image upload, title, contentNote: If user is not logged in, prompt them to log in manually and retry.
Step 3: Upload Cover Image
LinkedIn's cover image upload:
1. Find and click the cover image area/button (usually at top of editor, look for "Add a cover image" or camera icon) 2. Use browser_file_upload with the cover image path (from JSON output) 3. Wait for image upload to complete
Look for elements like:
- "Add a cover image" button
- Cover image placeholder area
- Camera/image icon at top of editor
Step 4: Fill Title
- Find the title input field (usually large text area at top with placeholder "Title")
- Use browser_type to input title (from JSON output)
Step 5: Paste Text Content (Python Clipboard)
Copy HTML to system clipboard using Python, then paste:
# Copy HTML to clipboard
python ~/.claude/skills/linkedin-article-publisher/scripts/copy_to_clipboard.py html --file /tmp/article_html.htmlThen in browser:
browser_click on editor content area
browser_press_key: Meta+vThis preserves all rich text formatting (H2, bold, links, lists).
Step 6: Insert Content Images (Block Index Positioning)
Key improvement: Use block_index for precise positioning instead of text matching.
Positioning principle
After pasting HTML, editor content consists of block elements (paragraphs, headers, quotes, etc.). Each image's block_index indicates it should be inserted after the Nth block element.
Operation steps
1. Get all block elements: Use browser_snapshot to get editor content, find all child elements under the textbox 2. Position by index: Click the block element at block_index position 3. Paste image: Copy image to clipboard then paste
For each content image (from content_images array):
# 1. Copy image to clipboard (with compression)
python ~/.claude/skills/linkedin-article-publisher/scripts/copy_to_clipboard.py image /path/to/img.jpg --quality 85# 2. Click the block element at block_index
# Example: if block_index=5, click the 6th block element (0-indexed)
browser_click on the element at position block_index in the editor
# 3. Paste image
browser_press_key: Meta+v
# 4. Wait for upload to complete (short timeout, returns immediately when done)
browser_wait_for time=3Positioning strategy
In browser_snapshot, editor content typically appears as:
textbox [ref=xxx]:
generic [ref=block0]: # block_index 0
- paragraph content
heading [ref=block1]: # block_index 1
- h2 content
generic [ref=block2]: # block_index 2
- paragraph content
...To insert image after block_index=5: 1. Find the 6th child element under editor textbox (0-indexed) 2. Click that element 3. Paste image
Note: Each inserted image shifts subsequent indices. Insert images in reverse order by block_index (highest to lowest) so earlier indices remain valid.
Reverse insertion example
If 3 images have block_index 5, 12, 27: 1. First insert block_index=27 image 2. Then insert block_index=12 image 3. Finally insert block_index=5 image
This way each insertion doesn't affect previously positioned locations.
Step 7: Save Draft
1. Verify content pasted (check if content appears correctly) 2. LinkedIn auto-saves drafts periodically (look for "Saved" indicator) 3. Optionally click "Publish" dropdown and select "Save as draft" 4. Report: "Draft saved. Review and publish manually."
Critical Rules
1. NEVER publish - Only save draft 2. First image = cover - Upload first image as cover image 3. Rich text conversion - Always convert Markdown to HTML before pasting 4. Use clipboard API - Paste via clipboard for proper formatting 5. Block index positioning - Use block_index for precise image placement 6. Reverse order insertion - Insert images from highest to lowest block_index 7. H1 title handling - H1 is used as title only, not included in body
Supported Formatting
- H2 headers (## )
- H3 headers (### )
- Blockquotes (> )
- Code blocks (``
...``) - converted to blockquotes for compatibility - Bold text (**)
- Italic text (*)
- Hyperlinks (text)
- Ordered lists (1. 2. 3.)
- Unordered lists (- )
- Paragraphs
Example Flow
User: "Publish /path/to/article.md to LinkedIn"
# Step 1: Parse Markdown
python ~/.claude/skills/linkedin-article-publisher/scripts/parse_markdown.py /path/to/article.md > /tmp/article.json
python ~/.claude/skills/linkedin-article-publisher/scripts/parse_markdown.py /path/to/article.md --html-only > /tmp/article_html.html2. Navigate to https://www.linkedin.com/article/new/ 3. Upload cover image (browser_file_upload for cover only) 4. Fill title (from JSON: title) 5. Copy & paste HTML:
python ~/.claude/skills/linkedin-article-publisher/scripts/copy_to_clipboard.py html --file /tmp/article_html.htmlThen: browser_press_key Meta+v 6. For each content image, in reverse order of block_index:
python copy_to_clipboard.py image /path/to/img.jpg --quality 85- Click block element at
block_indexposition - browser_press_key Meta+v
- Wait for upload to complete
7. Verify content in editor 8. "Draft saved. Please review and publish manually."
Best Practices
Why use block_index instead of text matching?
1. Precise positioning: Doesn't rely on text content, works even with similar paragraphs 2. Reliability: Index is deterministic, won't confuse similar text 3. Easy debugging: after_text kept for human verification
Why use Python instead of browser JavaScript?
1. More reliable: Python operates system clipboard directly, not limited by browser sandbox 2. Image compression: Compress before upload (--quality 85), reduces upload time 3. Code reuse: Scripts are fixed, no need to rewrite conversion logic each time 4. Easy debugging: Scripts can be tested independently
Wait strategy
Key understanding: browser_wait_for's time parameter is maximum wait time, not fixed delay. Operations return immediately when conditions are met.
- Bad:
time=10- if upload takes 2 seconds, remaining 8 seconds wasted - Good:
time=3- returns immediately when done, max wait 3 seconds
# Correct: Short time value, returns immediately when condition met
browser_wait_for time=3
# Wrong: Fixed long wait, wastes time
browser_wait_for time=10 # Unconditional 10-second waitPrinciple: Don't assume "how long it takes" - set reasonable maximum, let condition checking return quickly.
Image insertion efficiency
Each image's browser operations reduced from 5 steps to 2:
- Old: click -> add media -> media -> add photo -> file_upload
- New: click paragraph -> Meta+v
Cover image vs content images
- Cover image: Use browser_file_upload (dedicated upload button)
- Content images: Use Python clipboard + paste (more efficient)
LinkedIn-Specific Notes
Editor URL
- Primary:
https://www.linkedin.com/article/new/ - Alternative:
https://www.linkedin.com/pulse/(may redirect here)
UI Elements to Look For
- Cover image: "Add a cover image" text or camera icon at top
- Title: Large text input at top with "Title" placeholder
- Content: Rich text editor area below title
- Save: "Publish" dropdown button with "Save as draft" option
- Auto-save indicator: "Saved" or "Saving..." text
Rich Text Support
LinkedIn's editor supports:
- Headers (H2, H3)
- Bold, Italic
- Links (hyperlinks)
- Lists (ordered and unordered)
- Blockquotes
- Inline images
Authentication
- User must be logged into LinkedIn in the browser
- No Premium subscription required (unlike X Articles)
- Anyone with a LinkedIn account can publish articles
#!/usr/bin/env python3
"""
Copy image or HTML to system clipboard for LinkedIn Articles publishing.
Supports:
- Image files (jpg, png, gif, webp) - copies as image data
- HTML content - copies as rich text for paste
- Optional image compression before copying
Usage:
# Copy image to clipboard
python copy_to_clipboard.py image /path/to/image.jpg
# Copy image with compression (quality 0-100)
python copy_to_clipboard.py image /path/to/image.jpg --quality 80
# Copy HTML to clipboard
python copy_to_clipboard.py html "<p>Hello</p>"
# Copy HTML from file
python copy_to_clipboard.py html --file /path/to/content.html
macOS Requirements:
pip install Pillow pyobjc-framework-Cocoa
"""
import argparse
import io
import os
import sys
from pathlib import Path
def compress_image(image_path: str, quality: int = 85, max_size: tuple = (2000, 2000)) -> bytes:
"""Compress image and return as bytes."""
from PIL import Image
img = Image.open(image_path)
# Convert to RGB if necessary (for JPEG)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Resize if too large
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=quality, optimize=True)
return buffer.getvalue()
def copy_image_to_clipboard_macos(image_path: str, quality: int = None) -> bool:
"""Copy image to macOS clipboard using AppKit."""
try:
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
from Foundation import NSData
# Compress if quality specified, otherwise use original
if quality:
image_data = compress_image(image_path, quality)
else:
with open(image_path, 'rb') as f:
image_data = f.read()
# Create NSData from image bytes
ns_data = NSData.dataWithBytes_length_(image_data, len(image_data))
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Determine type based on file extension
ext = Path(image_path).suffix.lower()
if ext in ('.png',):
pasteboard.setData_forType_(ns_data, NSPasteboardTypePNG)
else:
# For JPEG and others, use TIFF (more compatible)
from PIL import Image
img = Image.open(io.BytesIO(image_data))
tiff_buffer = io.BytesIO()
img.save(tiff_buffer, format='TIFF')
tiff_data = NSData.dataWithBytes_length_(tiff_buffer.getvalue(), len(tiff_buffer.getvalue()))
pasteboard.setData_forType_(tiff_data, NSPasteboardTypeTIFF)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install Pillow pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying image: {e}", file=sys.stderr)
return False
def copy_html_to_clipboard_macos(html: str) -> bool:
"""Copy HTML to macOS clipboard as rich text."""
try:
from AppKit import NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString
from Foundation import NSData
# Get pasteboard and clear it
pasteboard = NSPasteboard.generalPasteboard()
pasteboard.clearContents()
# Set HTML content
html_data = html.encode('utf-8')
ns_data = NSData.dataWithBytes_length_(html_data, len(html_data))
pasteboard.setData_forType_(ns_data, NSPasteboardTypeHTML)
# Also set plain text version
pasteboard.setString_forType_(html, NSPasteboardTypeString)
return True
except ImportError as e:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("Install with: pip install pyobjc-framework-Cocoa", file=sys.stderr)
return False
except Exception as e:
print(f"Error copying HTML: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description='Copy to clipboard for LinkedIn Articles')
subparsers = parser.add_subparsers(dest='type', required=True)
# Image subcommand
img_parser = subparsers.add_parser('image', help='Copy image to clipboard')
img_parser.add_argument('path', help='Path to image file')
img_parser.add_argument('--quality', type=int, default=None,
help='JPEG quality (1-100), enables compression')
img_parser.add_argument('--max-width', type=int, default=2000,
help='Max width for resize')
img_parser.add_argument('--max-height', type=int, default=2000,
help='Max height for resize')
# HTML subcommand
html_parser = subparsers.add_parser('html', help='Copy HTML to clipboard')
html_parser.add_argument('content', nargs='?', help='HTML content')
html_parser.add_argument('--file', '-f', help='Read HTML from file')
args = parser.parse_args()
if args.type == 'image':
if not os.path.exists(args.path):
print(f"Error: Image not found: {args.path}", file=sys.stderr)
sys.exit(1)
success = copy_image_to_clipboard_macos(args.path, args.quality)
if success:
print(f"Image copied to clipboard: {args.path}")
if args.quality:
print(f" (compressed with quality={args.quality})")
sys.exit(0 if success else 1)
elif args.type == 'html':
if args.file:
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
with open(args.file, 'r', encoding='utf-8') as f:
html = f.read()
elif args.content:
html = args.content
else:
# Read from stdin
html = sys.stdin.read()
success = copy_html_to_clipboard_macos(html)
if success:
print(f"HTML copied to clipboard ({len(html)} chars)")
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Parse Markdown for LinkedIn Articles publishing.
Extracts:
- Title (from first H1/H2 or first line)
- Cover image (first image)
- Content images with block index for precise positioning
- HTML content (images stripped)
Usage:
python parse_markdown.py <markdown_file> [--output json|html]
Output (JSON):
{
"title": "Article Title",
"cover_image": "/path/to/cover.jpg",
"content_images": [
{"path": "/path/to/img.jpg", "block_index": 3, "after_text": "context..."},
...
],
"html": "<p>Content...</p><h2>Section</h2>...",
"total_blocks": 25
}
The block_index indicates which block element (0-indexed) the image should follow.
This allows precise positioning without relying on text matching.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
def split_into_blocks(markdown: str) -> list[str]:
"""Split markdown into logical blocks (paragraphs, headers, quotes, code blocks, etc.)."""
blocks = []
current_block = []
in_code_block = False
code_block_lines = []
lines = markdown.split('\n')
for line in lines:
stripped = line.strip()
# Handle code block boundaries
if stripped.startswith('```'):
if in_code_block:
# End of code block
in_code_block = False
if code_block_lines:
# Mark as code block with special prefix for later processing
# Use ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___ to preserve content
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
code_block_lines = []
else:
# Start of code block
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
in_code_block = True
continue
# If inside code block, collect ALL lines (including empty lines)
if in_code_block:
code_block_lines.append(line)
continue
# Empty line signals end of block
if not stripped:
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
continue
# Headers, blockquotes are their own blocks
if stripped.startswith(('#', '>')):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
# Image on its own line is its own block
if re.match(r'^!\[.*\]\(.*\)$', stripped):
if current_block:
blocks.append('\n'.join(current_block))
current_block = []
blocks.append(stripped)
continue
current_block.append(line)
if current_block:
blocks.append('\n'.join(current_block))
# Handle unclosed code block
if code_block_lines:
blocks.append('___CODE_BLOCK_START___' + '\n'.join(code_block_lines) + '___CODE_BLOCK_END___')
return blocks
def extract_images_with_block_index(markdown: str, base_path: Path) -> tuple[list[dict], str, int]:
"""Extract images with their block index position.
Returns:
(image_list, markdown_without_images, total_blocks)
"""
blocks = split_into_blocks(markdown)
images = []
clean_blocks = []
img_pattern = re.compile(r'^!\[([^\]]*)\]\(([^)]+)\)$')
for i, block in enumerate(blocks):
match = img_pattern.match(block.strip())
if match:
alt_text = match.group(1)
img_path = match.group(2)
# Resolve relative paths
if not os.path.isabs(img_path):
full_path = str(base_path / img_path)
else:
full_path = img_path
# block_index is the index in clean_blocks (without images)
# i.e., this image should be inserted after clean_blocks[block_index-1]
block_index = len(clean_blocks)
# Get context from previous block for reference
after_text = ""
if clean_blocks:
prev_block = clean_blocks[-1].strip()
# Get last line of previous block
lines = [l for l in prev_block.split('\n') if l.strip()]
after_text = lines[-1][:80] if lines else ""
images.append({
"path": full_path,
"alt": alt_text,
"block_index": block_index,
"after_text": after_text # Keep for reference/debugging
})
else:
clean_blocks.append(block)
clean_markdown = '\n\n'.join(clean_blocks)
return images, clean_markdown, len(clean_blocks)
def extract_title(markdown: str) -> tuple[str, str]:
"""Extract title from first H1, H2, or first non-empty line.
Returns:
(title, markdown_without_title): Title string and markdown with H1 title removed.
If title is from H1, it's removed from markdown to avoid duplication.
"""
lines = markdown.strip().split('\n')
title = "Untitled"
title_line_idx = None
for idx, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# H1 - use as title and mark for removal
if stripped.startswith('# '):
title = stripped[2:].strip()
title_line_idx = idx
break
# H2 - use as title but don't remove (it's a section header)
if stripped.startswith('## '):
title = stripped[3:].strip()
break
# First non-empty, non-image line
if not stripped.startswith('!['):
title = stripped[:100]
break
# Remove H1 title line from markdown to avoid duplication
if title_line_idx is not None:
lines.pop(title_line_idx)
markdown = '\n'.join(lines)
return title, markdown
def markdown_to_html(markdown: str) -> str:
"""Convert markdown to HTML for LinkedIn Articles rich text paste."""
html = markdown
# Process code blocks first (marked with ___CODE_BLOCK_START___ and ___CODE_BLOCK_END___)
# Convert to blockquote format for better compatibility
def convert_code_block(match):
code_content = match.group(1)
lines = code_content.strip().split('\n')
# Join non-empty lines with <br> for display
formatted = '<br>'.join(line for line in lines if line.strip())
return f'<blockquote>{formatted}</blockquote>'
html = re.sub(r'___CODE_BLOCK_START___(.*?)___CODE_BLOCK_END___', convert_code_block, html, flags=re.DOTALL)
# Headers (H2 only, H1 is title)
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
# Bold
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
# Italic
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
# Links
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
# Blockquotes (regular markdown blockquotes, not code blocks)
html = re.sub(r'^> (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
# Unordered lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Ordered lists
html = re.sub(r'^\d+\. (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
# Wrap consecutive <li> in <ul>
html = re.sub(r'((?:<li>.*?</li>\n?)+)', r'<ul>\1</ul>', html)
# Paragraphs - split by double newlines
parts = html.split('\n\n')
processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue
# Skip if already a block element
if part.startswith(('<h2>', '<h3>', '<blockquote>', '<ul>', '<ol>')):
processed_parts.append(part)
else:
# Wrap in paragraph, convert single newlines to <br>
part = part.replace('\n', '<br>')
processed_parts.append(f'<p>{part}</p>')
return ''.join(processed_parts)
def parse_markdown_file(filepath: str) -> dict:
"""Parse a markdown file and return structured data."""
path = Path(filepath)
base_path = path.parent
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Extract title first (and remove H1 from markdown)
title, content = extract_title(content)
# Extract images with block indices
images, clean_markdown, total_blocks = extract_images_with_block_index(content, base_path)
# Convert to HTML
html = markdown_to_html(clean_markdown)
# Separate cover image from content images
cover_image = images[0]["path"] if images else None
content_images = images[1:] if len(images) > 1 else []
# Adjust block_index for content images (subtract 1 since cover image is removed)
# The first content image's block_index was calculated including cover image's position
return {
"title": title,
"cover_image": cover_image,
"content_images": content_images,
"html": html,
"total_blocks": total_blocks,
"source_file": str(path.absolute())
}
def main():
parser = argparse.ArgumentParser(description='Parse Markdown for LinkedIn Articles')
parser.add_argument('file', help='Markdown file to parse')
parser.add_argument('--output', choices=['json', 'html'], default='json',
help='Output format (default: json)')
parser.add_argument('--html-only', action='store_true',
help='Output only HTML content')
args = parser.parse_args()
if not os.path.exists(args.file):
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
result = parse_markdown_file(args.file)
if args.html_only:
print(result['html'])
elif args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(result['html'])
if __name__ == '__main__':
main()