diff --git a/server.py b/server.py index b605551..419a2d3 100644 --- a/server.py +++ b/server.py @@ -365,12 +365,415 @@ async def get_sec_filings( # Stringify the SEC filings return json.dumps(filings, indent=2) +ARTICLE_TOOLS_BASE = "https://eternityspring.github.io/article-tools" + + +@mcp.tool() +def get_article_tool_url(tool: str) -> str: + """Get the URL for an article formatting tool. + + Args: + tool: The tool name. One of: 'cover', 'md-to-wechat', 'md-to-x', 'qrcode', 'index'. + """ + valid_tools = {"cover", "md-to-wechat", "md-to-x", "qrcode", "index"} + if tool not in valid_tools: + return f"Unknown tool '{tool}'. Valid options: {', '.join(sorted(valid_tools))}" + if tool == "index": + return ARTICLE_TOOLS_BASE + "/" + return f"{ARTICLE_TOOLS_BASE}/{tool}.html" + + +@mcp.tool() +def format_markdown_for_wechat(content: str) -> str: + """Convert markdown to WeChat public account compatible HTML. + + Supports headers, bold/italic, inline code, links, unordered/ordered lists, + blockquotes, fenced code blocks, horizontal rules, and tables. + + Args: + content: Markdown text to convert. + """ + import re + + lines = content.split("\n") + output = [] + i = 0 + while i < len(lines): + line = lines[i] + + # Fenced code block + if re.match(r"^```", line): + lang = line[3:].strip() + code_lines = [] + i += 1 + while i < len(lines) and not re.match(r"^```", lines[i]): + code_lines.append(lines[i]) + i += 1 + code = "\n".join(code_lines) + output.append( + f'
'
+ f'{code}'
+ )
+ i += 1
+ continue
+
+ # Table (detect by | separator)
+ if "|" in line and re.match(r"^\|", line.strip()):
+ table_lines = []
+ while i < len(lines) and "|" in lines[i]:
+ table_lines.append(lines[i])
+ i += 1
+ # Remove separator row (---|---)
+ rows = [r for r in table_lines if not re.match(r"^[\s|:-]+$", r)]
+ html = '' + f'{_inline_md(inner)}' + ) + continue + + # Headers + m = re.match(r"^(#{1,6})\s+(.*)", line) + if m: + level = len(m.group(1)) + text = m.group(2).strip() + size = {1: "24px", 2: "20px", 3: "18px", 4: "16px", 5: "15px", 6: "14px"}[level] + color = "#07c160" if level <= 2 else "#333" + output.append( + f'
{text}
' + ) + i += 1 + continue + + # Blank line + if line.strip() == "": + i += 1 + continue + + # Unordered list + m = re.match(r"^[-*+]\s+(.*)", line) + if m: + output.append(f'⢠{_inline_md(m.group(1))}
') + i += 1 + continue + + # Ordered list + m = re.match(r"^(\d+)\.\s+(.*)", line) + if m: + output.append( + f'' + f'{m.group(1)}. {_inline_md(m.group(2))}
' + ) + i += 1 + continue + + # Regular paragraph + output.append(f'{_inline_md(line)}
') + i += 1 + + return "\n".join(output) + + +def _inline_md(text: str) -> str: + """Convert inline markdown to HTML.""" + import re + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) + text = re.sub(r"`(.+?)`", r'\1', text)
+ text = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', text)
+ return text
+
+
+@mcp.tool()
+def format_markdown_for_x(
+ content: str,
+ max_chars: int = 270,
+ suggest_hashtags: bool = True,
+) -> str:
+ """Split and format content into an X (Twitter) thread, with hashtag suggestions.
+
+ Each post is kept under max_chars. The last post includes suggested hashtags
+ extracted from the content's keywords.
+
+ Args:
+ content: The text or markdown content to format.
+ max_chars: Maximum characters per post (default 270, leaving room for numbering).
+ suggest_hashtags: Append suggested hashtags to the last post (default True).
+ """
+ import re
+
+ # Strip markdown to plain text
+ text = re.sub(r"#{1,6}\s+", "", content)
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"__(.+?)__", r"\1", text)
+ text = re.sub(r"\*(.+?)\*", r"\1", text)
+ text = re.sub(r"_(.+?)_", r"\1", text)
+ text = re.sub(r"`(.+?)`", r"\1", text)
+ text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
+
+ # Extract hashtag candidates from existing #tags and capitalised words
+ raw_tags = re.findall(r"#(\w+)", content)
+ cap_words = re.findall(r"\b([A-Z][a-zA-Z]{3,})\b", content)
+ candidates = list(dict.fromkeys(raw_tags + cap_words))[:5]
+ hashtags = " ".join(f"#{t}" for t in candidates)
+
+ # Split into sentences
+ sentences = re.split(r"(?<=[ćļ¼ļ¼.!?])\s*", text)
+ sentences = [s.strip() for s in sentences if s.strip()]
+
+ posts, current = [], ""
+ for sentence in sentences:
+ if len(current) + len(sentence) + 1 <= max_chars:
+ current = (current + " " + sentence).strip()
+ else:
+ if current:
+ posts.append(current)
+ if len(sentence) > max_chars:
+ for j in range(0, len(sentence), max_chars):
+ posts.append(sentence[j:j + max_chars])
+ current = ""
+ else:
+ current = sentence
+ if current:
+ posts.append(current)
+
+ # Append hashtags to last post if they fit
+ if suggest_hashtags and hashtags and posts:
+ last = posts[-1]
+ candidate_last = last + "\n" + hashtags
+ if len(candidate_last) <= max_chars + 30:
+ posts[-1] = candidate_last
+
+ total = len(posts)
+ numbered = [f"[{idx + 1}/{total}] {post}" for idx, post in enumerate(posts)]
+ return "\n---\n".join(numbered)
+
+
+@mcp.tool()
+def format_markdown_for_threads(content: str, max_chars: int = 500) -> str:
+ """Split and format content into a Threads thread.
+
+ Each post is kept under max_chars (Threads limit is 500). Posts are
+ separated by a blank line. Non-final posts end with š to signal continuation.
+
+ Args:
+ content: The text or markdown content to format.
+ max_chars: Maximum characters per post (default 500).
+ """
+ import re
+
+ # Strip markdown to plain text
+ text = re.sub(r"#{1,6}\s+", "", content)
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"__(.+?)__", r"\1", text)
+ text = re.sub(r"\*(.+?)\*", r"\1", text)
+ text = re.sub(r"_(.+?)_", r"\1", text)
+ text = re.sub(r"`(.+?)`", r"\1", text)
+ text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
+
+ # Split into sentences
+ sentences = re.split(r"(?<=[ćļ¼ļ¼.!?])\s*", text)
+ sentences = [s.strip() for s in sentences if s.strip()]
+
+ # Pack sentences into posts within max_chars (reserve 2 chars for š)
+ limit = max_chars - 2
+ posts, current = [], ""
+ for sentence in sentences:
+ if len(current) + len(sentence) + 1 <= limit:
+ current = (current + "\n" + sentence).strip()
+ else:
+ if current:
+ posts.append(current)
+ if len(sentence) > limit:
+ for j in range(0, len(sentence), limit):
+ posts.append(sentence[j:j + limit])
+ current = ""
+ else:
+ current = sentence
+ if current:
+ posts.append(current)
+
+ result = []
+ for i, post in enumerate(posts):
+ if i < len(posts) - 1:
+ result.append(post + "\nš")
+ else:
+ result.append(post)
+
+ return "\n\n".join(result)
+
+
+@mcp.tool()
+def generate_article_cover_html(
+ title: str,
+ subtitle: str = "",
+ author: str = "",
+ bg_color: str = "#07c160",
+ text_color: str = "#ffffff",
+ width: int = 900,
+ height: int = 383,
+) -> str:
+ """Generate a standalone HTML file for an article cover image.
+
+ The returned HTML can be saved as a .html file and opened in a browser
+ to screenshot/export as the article cover. Default dimensions match
+ WeChat public account cover size (900Ć383 px).
+
+ Args:
+ title: Main article title.
+ subtitle: Optional subtitle or tagline.
+ author: Author name shown at bottom-right.
+ bg_color: Background color (default WeChat green #07c160).
+ text_color: Text color (default #ffffff).
+ width: Cover width in pixels (default 900).
+ height: Cover height in pixels (default 383).
+ """
+ subtitle_html = (
+ f'