Skill: miraclemin/notion-publisher (version 1.0.0)

--- name: notion-publisher description: Publish articles to Notion using cached local copies of the target database's default Notion template when available. Use this skill when the user types /notion-publisher, asks to publish an article to Notion, create a Notion article page, draft a post into a Notion database, use an existing Notion article template, refresh the local template cache, or infer article metadata such as slug, summary, tags, category, status, cover, and template before creating a Notion page. --- # Notion Publisher Skill ## Trigger Use this skill when the user types `/notion-publisher` or asks to publish, draft, or create an article page in a Notion database. ## Shared Config Path `~/.notion_publish/notion-publisher-config.json` Only store reusable publishing preferences: ```json { "default_status": "Draft", "template_strategy": "ask_each_time" } ``` Do not store database IDs, data source IDs, tags, category choices, cover choices, article content, Notion tokens, or private workspace URLs in the config file. Ask for the target database every time. ## Detect Platform Before choosing a runtime, detect whether the current client is OpenClaw by running: ```bash which openclaw 2>/dev/null && echo "PLATFORM=openclaw" || echo "PLATFORM=other" ``` If `PLATFORM=openclaw`, assume Notion MCP is not available and prefer the bundled CLI runtime. If `PLATFORM=other`, use MCP when available and fall back to the CLI runtime when MCP is missing. ## Notion Runtime Options This skill supports MCP clients and command-only clients such as OpenClaw. Preferred order: 1. If Notion MCP tools are available, use MCP. 2. If MCP is not available but shell commands are available, use the bundled CLI runtime: `scripts/notion_publisher.py` 3. If neither MCP nor shell command execution is available, generate the article Markdown and ask the user to publish it manually. The CLI runtime uses the official Notion API and requires `NOTION_TOKEN` in the environment or in: ```text ~/.notion_publish/.env ``` Example: ```text NOTION_TOKEN=secret_xxx ``` The target Notion database must be shared with that Notion integration. To get a Notion token: 1. Open Notion's integrations/creator dashboard. 2. Create a new internal integration in the target workspace. 3. Open the integration's Configuration tab and copy the Internal Integration Secret. 4. Enable the capabilities needed for publishing, including read content, update content, and insert content. 5. Share the target Notion database or parent page with the integration through the Content access tab or Notion's Add connection menu. 6. Save the secret locally in `~/.notion_publish/.env` as `NOTION_TOKEN=secret_xxx`. Never commit it to a repository. ### MCP Tool Names Use whichever Notion MCP tools are available in the current client. Codex tool names: - `mcp__notion__notion_search` - `mcp__notion__notion_fetch` - [REDACTED_SECRET] - `mcp__notion__notion_update_page` Claude Code / Claude plugin tool names: - [REDACTED_SECRET] or `notion-search` - `notion-fetch` - `notion-create-pages` - `notion-update-page` If no Notion MCP tools are available, do not stop automatically. Use the CLI runtime if shell commands are available. CLI examples: ```bash python3 scripts/notion_publisher.py publish \ --database-id "NOTION_DATABASE_ID_OR_URL" \ --title "Article title" \ --body-file article.md \ --status Draft \ --type Post ``` The CLI also accepts a Notion data source directly: ```bash python3 scripts/notion_publisher.py publish \ --data-source-id "collection://DATA_SOURCE_ID" \ --title "Article title" \ --body-file article.md ``` If both `--database-id` and `--data-source-id` are omitted, the CLI asks the user for a Notion database/data source ID or URL. By default, it does not save database IDs. Use `--save-database` only if the user explicitly wants the database remembered as a local prompt default. CLI update examples: ```bash python3 scripts/notion_publisher.py update \ --page-id "NOTION_PAGE_ID_OR_URL" \ --mode replace \ --body-file article.md \ --summary "Updated summary" ``` ```bash python3 scripts/notion_publisher.py update \ --page-id "NOTION_PAGE_ID_OR_URL" \ --mode append \ --body-file appendix.md ``` CLI search example: ```bash python3 scripts/notion_publisher.py search \ --data-source-id "collection://DATA_SOURCE_ID" \ --query "keyword" \ --limit 10 ``` CLI search returns JSON rows with `id`, `title`, `status`, `type`, `category`, `slug`, `date`, and `url`. Important search behavior: - MCP search is semantic workspace/data-source search. - CLI search uses Notion API data source query plus local keyword filtering over title, summary, slug, status, type, category, and tags. - Use CLI search when OpenClaw needs a page ID before calling `update`. ## Workflow ### 1. Check Config Read: ```bash cat ~/.notion_publish/notion-publisher-config.json 2>/dev/null ``` If the file does not exist, create it immediately: ```bash mkdir -p ~/.notion_publish cat > ~/.notion_publish/notion-publisher-config.json <<'EOF' { "default_status": "Draft", "template_strategy": "ask_each_time" } EOF ``` Then continue. During the article confirmation step, show `default_status` and `template_strategy` to the user and let them override values for that article. Do not save cover preferences. Cover is selected every time. ### 2. Ask for Target Database Every Time Always ask the user to choose a target database. Do not reuse a cached database. Offer these input modes: ```text Please choose a target Notion database: 1. Paste a Notion database URL 2. Enter a database ID 3. Enter keywords to search 4. Press Enter to search for likely article databases ``` After the user chooses, fetch the database schema with the current environment's Notion fetch tool. Read the data source ID, writable properties, select/multi-select options, and available Notion database templates. In CLI mode, pass the database ID or URL to `scripts/notion_publisher.py`. If the user does not provide one, let the CLI prompt for it. Do not save the database unless the user explicitly chooses `--save-database`. ### 3. Use Cached Default Templates First Before fetching a Notion template page, read: `templates/notion-defaults/catalog.md` If a local cached template matches the target database or template ID, use the local Markdown file as the article structure source. Fetch the Notion template page only when: - There is no matching local cache. - The user explicitly asks to refresh, sync, or re-read the Notion template. When refreshing, update the cache file under `templates/notion-defaults/` and update `cached_at`. Do not include private database IDs or workspace names if the skill will be shared publicly. When using a cached or bundled template, preserve its visual structure: - Keep emoji in headings and callouts. - Keep callout colors, quote blocks, toggles, dividers, and section order unless the user asks to simplify. - Replace placeholder headings with meaningful article-specific headings. Prefer neutral numbering such as `一、...` and `二、...`; avoid generic subheadings like `观点一` or `观点二`. - Do not flatten the template into plain paragraphs. ### 4. Collect Article Basics Ask: ```text Title: Body: paste content, type "generate", or leave blank to draft later. ``` Infer and show a confirmation block: ```text slug: summary: category: tags: status: cover: inline images: template strategy: ``` Rules: - Use the database's real schema. Do not invent property names. - Pick categories and tags only from existing database options unless the user explicitly asks to create or change schema. - Use `default_status` from the shared config as the suggested status, but show it for confirmation each time. - Generate `slug` as English kebab-case. - Keep `summary` to one or two concise sentences. ### 5. Ask for Cover Every Time Cover is not saved in config. Always ask: ```text Choose cover: 1. Search a recommended cover by article topic 2. Search with custom keywords 3. Custom image URL 4. No cover ``` Cover search rules: - Infer the article topic from the title, summary, tags, and body. - For technical articles, prefer real nebula, deep-space, telescope, satellite, or astronomy images. NASA, ESA, Hubble, and Webb official image sources are good defaults. - For AI, LLM, agent, code, infrastructure, or developer-tool articles, default to astronomy/deep-space covers unless the user asks for another style. - For essays, reflections, product thinking, culture, history, or other non-technical articles, prefer well-known public-domain or museum collection artworks by major artists when they fit the article mood or theme. - For non-technical articles, search for a real image that matches the article's actual subject instead of using a generic decoration. - Use the current environment's available web/image search capability when search is requested or when recommending a cover. - Prefer stable direct image URLs from official sites, open-license sources, or pages that are appropriate to cite. - Prefer simple direct `.jpg`, `.jpeg`, `.png`, or `.webp` URLs. Avoid URLs with complex crop/query parameters when a clean direct image URL is available. - Do not use guessed Notion built-in Gallery cover URLs. Notion's UI Gallery covers are not exposed as stable public URLs through the current Notion MCP flow. - For artwork covers, prefer museum/open-access image sources such as Art Institute of Chicago IIIF, Cleveland Museum of Art Open Access, The Met Open Access, or Rijksmuseum public-domain images. - Do not hotlink random Google Images thumbnails, unstable CDN thumbnails, or images that are unrelated to the article. - Show the recommended cover URL and short source note to the user, then ask for confirmation before creating or updating the Notion page. - If the confirmed cover does not appear in Notion, retry with a simpler stable direct image URL. Do not use plain-color or mostly solid-color covers by default. Avoid gradient covers unless the user explicitly asks for a simple gradient. Preferred cover source strategy: 1. Technical / AI / LLM / coding articles: - Search NASA, ESA, Hubble, or Webb official image pages. - Use direct official image URLs only. Prefer clean `.jpg`, `.jpeg`, `.png`, or `.webp` URLs. - Avoid temporary signed URLs, heavily cropped URLs, and arbitrary CDN thumbnails. 2. Art / reflection / essay / product-thinking articles: - Prefer Art Institute of Chicago first because it has stable IIIF URLs. - Search the API for public-domain artworks with `image_id`, then build: `https://www.artic.edu/iiif/2/{image_id}/full/843,/0/default.jpg` - Good search query shape: `https://api.artic.edu/api/v1/artworks/search?q={keywords}&query[term][is_public_domain]=true&fields=id,title,artist_display,image_id,is_public_domain` 3. Alternate artwork sources: - The Met Open Access: use the object API's `primaryImage` or `primaryImageSmall` JPEG URL when `isPublicDomain` is true. - Rijksmuseum: use IIIF URLs like `https://iiif.micr.io/{id}/full/max/0/default.png` when an image id is available. - Cleveland Museum of Art Open Access: use API-provided image URLs when available. 4. Final validation before using a cover: - The URL must be a direct image URL or a documented IIIF image URL. - The image should visually match the article topic or mood. - Do not reuse the same cover for multiple newly generated articles in the same batch unless the user asks. - Show title/source/URL to the user when practical, then use the confirmed URL as the Notion page `cover`. ### 6. Ask for Inline Images Ask whether to insert images inside the article body: ```text Insert inline images? 1. No inline images 2. Search by article title and summary 3. Search by custom keywords 4. Use custom image URL ``` If the user chooses search: - Generate 2-4 image search keywords from the title, summary, and article topic. - Use the current environment's available web/image search capability. - Prefer stable image URLs from official sites, open-license sources, or pages that are appropriate to cite. - Avoid hotlinking random copyrighted images from Google Images or unstable CDN thumbnails. - Show candidate URLs to the user and ask for confirmation before inserting. When generating a complete article body, include at least one relevant inline image unless the user explicitly chooses "No inline images". - Place the first inline image after the opening callout or intro paragraph. - Put inline images on their own line with `<empty-block/>` before and after the image block so Notion App has the best chance of rendering them as native image blocks, not plain Markdown text. - The page cover does not count as an inline image. - In batch publishing mode, do not stop for each image if the user has already asked to publish everything; choose stable, relevant image URLs using the cover source strategy above and avoid reusing the same image across the batch. - For technical / AI / LLM / coding articles, inline images may use the same source families as covers: NASA, ESA, Hubble, Webb, or stable astronomy image URLs. Prefer official NASA/ESA/Webb asset URLs over Wikimedia when the user wants the image to show inside Notion App. - For non-technical or reflection articles, prefer museum/open-access artwork URLs. - After updating an existing page, fetch the page and verify the image line is present. If Notion App still shows the image Markdown as text while the frontend renders it, explain that the current MCP update path may have written text that the frontend parses but Notion App does not convert into a native image block; retry with a simpler official direct image URL and explicit surrounding `<empty-block/>` blocks. Insert confirmed images with Notion-flavored Markdown: ```markdown <empty-block/> ![Caption](https://example.com/image.jpg) <empty-block/> ``` Place images near the relevant section. Do not insert images that are unrelated, low quality, broken, or likely to violate usage rights. ### 7. Choose Template Strategy Use `template_strategy` from config as the default suggestion: - `ask_each_time`: ask every time. - `use_cached_notion_template`: default to creating with the Notion template ID. - `generate_from_cached_template`: default to generating complete content from the cached local template. Still let the user override. Show: ```text Choose template strategy: 1. Use the Notion database template to create a page 2. Generate complete content from the cached local template 3. Use a bundled fallback template 4. No template ``` Important: - If using `template_id`, do not pass `content`. - If generating complete content, do not pass `template_id`; read the local template and pass `content`. - Remove all unreplaced placeholders before creating the page. - Preserve rich Notion-flavored Markdown styling from the selected template, including emoji headings, callouts, toggles, quotes, and empty-block spacing. ### 8. Fallback Templates If no database template cache exists, read `templates/catalog.md` and offer: 1. Clean Essay 2. Technical Deep Dive 3. Product Update 4. Knowledge Note 5. Minimal Blog 6. No Template ### 9. Create Page If using MCP, call the current environment's Notion create pages tool with: ```json { "parent": { "type": "data_source_id", "data_source_id": "..." }, "pages": [{ "properties": {}, "cover": "optional cover URL", "template_id": "only when using a Notion database template", "content": "only when writing generated Markdown content" }] } ``` Use expanded date property fields such as `date:date:start` and `date:date:is_datetime` when required by the schema. If using CLI mode, create or save the generated article as Markdown and run: ```bash python3 scripts/notion_publisher.py publish \ --database-id "NOTION_DATABASE_ID_OR_URL" \ --title "Article title" \ --body-file article.md \ --status Draft \ --type Post \ --category "LLM" \ --tags "LLM,工具" \ --slug "article-slug" \ --summary "Short summary" \ --date "2025-06-12" \ --cover "https://example.com/cover.jpg" ``` CLI mode writes inline images as native Notion `image` blocks through the official API. This is preferred when the user needs images to render inside Notion App, not only in a frontend that parses Markdown. ### 10. Update Page When Needed If a page was created from a Notion database template and the user later wants to replace or insert article content, call the current environment's Notion update page tool with `replace_content` or targeted content updates. In CLI mode, use `scripts/notion_publisher.py update`. Supported update modes: - `--mode replace`: delete/archive existing child blocks, then append the new article blocks. This matches MCP `replace_content`. - `--mode append`: keep existing child blocks and append the new blocks at the end. The CLI update command can also update page properties and cover: ```bash python3 scripts/notion_publisher.py update \ --page-id "NOTION_PAGE_ID_OR_URL" \ --mode replace \ --body-file article.md \ --status Published \ --type Post \ --category "LLM" \ --tags "LLM,工具" \ --slug "article-slug" \ --summary "Short summary" \ --date "2025-06-12" \ --cover "https://example.com/cover.jpg" ``` CLI update mode writes inline images as native Notion `image` blocks and should be preferred in OpenClaw when the user needs images to render inside Notion App. ### 11. Search Pages When Needed If using MCP, search through the target data source with the current environment's Notion search tool, then fetch candidate pages and inspect properties before updating. If using CLI mode, run: ```bash python3 scripts/notion_publisher.py search \ --data-source-id "collection://DATA_SOURCE_ID" \ --query "keyword" \ --status Draft \ --limit 25 ``` Use the returned `id` as `--page-id` for `scripts/notion_publisher.py update`. ## Reset When the user says `/notion-publisher reset`, delete: `~/.notion_publish/notion-publisher-config.json` Then re-run the initial config flow. Do not delete template caches unless the user explicitly asks. ## Safety - Do not publish pages as `Published` unless the user confirms. - Do not store Notion tokens, workspace URLs, private database IDs, page IDs, or article drafts in the shared config. - Do not commit private cached templates to public repositories. - If creating an open-source package, include only generic example templates and example config files.

path: scripts/notion_publisher.py
content: #!/usr/bin/env python3
"""Publish simple articles to Notion without MCP.

This CLI is intentionally dependency-free so it can run in clients that support
shell commands but do not provide Notion MCP tools.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any


CONFIG_DIR = Path.home() / ".notion_publish"
CONFIG_PATH = CONFIG_DIR / "notion-publisher-config.json"
DEFAULT_CONFIG = {
    "default_status": "Draft",
    "template_strategy": "ask_each_time",
}
DEFAULT_SCHEMA = {
    "title": {"type": "title"},
    "status": {"type": "select"},
    "type": {"type": "select"},
    "category": {"type": "select"},
    "tags": {"type": "multi_select"},
    "slug": {"type": "rich_text"},
    "summary": {"type": "rich_text"},
    "date": {"type": "date"},
}
NOTION_VERSION = os.environ.get("NOTION_VERSION", "2026-03-11")
API_BASE = "https://api.notion.com/v1"


class NotionError(RuntimeError):
    pass


def load_config() -> dict[str, Any]:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    if not CONFIG_PATH.exists():
        CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n", encoding="utf-8")
        return dict(DEFAULT_CONFIG)

    try:
        data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"Invalid config JSON: {CONFIG_PATH}: {exc}") from exc

    changed = False
    for key, value in DEFAULT_CONFIG.items():
        if key not in data:
            data[key] = value
            changed = True
    if changed:
        CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    return data


def save_config(config: dict[str, Any]) -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def read_env_file() -> None:
    env_path = CONFIG_DIR / ".env"
    if not env_path.exists():
        return
    for raw_line in env_path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        os.environ.setdefault(key, value)


def parse_notion_id(value: str) -> str:
    """Extract a Notion UUID from an ID or URL."""
    text = value.strip()
    if not text:
        return ""

    parsed = urllib.parse.urlparse(text)
    query = urllib.parse.parse_qs(parsed.query)
    if text.startswith("collection://"):
        text = text.removeprefix("collection://")
    elif query.get("p"):
        text = query["p"][0]
    else:
        text = (parsed.netloc or parsed.path.rsplit("/", 1)[-1]) if parsed.scheme else text

    text = text.split("?")[0].replace("-", "")
    match = re.search(r"([0-9a-fA-F]{32})", text)
    if not match:
        raise ValueError(f"Could not find a Notion ID in: {value}")
    raw = match.group(1).lower()
    return f"{raw[0:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}"


class NotionClient:
    def __init__(self, token: str) -> None:
        self.token = token

    def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        data = None if payload is None else json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            f"{API_BASE}{path}",
            data=data,
            method=method,
            headers={
                "Authorization": f"Bearer {self.token}",
                "Notion-Version": NOTION_VERSION,
                "Content-Type": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(req, timeout=30) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            raise NotionError(f"Notion API {exc.code} {exc.reason}: {body}") from exc
        except urllib.error.URLError as exc:
            raise NotionError(f"Network error calling Notion API: {exc}") from exc

    def get_database(self, database_id: str) -> dict[str, Any]:
        return self.request("GET", f"/databases/{database_id}")

    def get_data_source(self, data_source_id: str) -> dict[str, Any]:
        return self.request("GET", f"/data_sources/{data_source_id}")

    def query_data_source(self, data_source_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        return self.request("POST", f"/data_sources/{data_source_id}/query", payload)

    def query_database(self, database_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        return self.request("POST", f"/databases/{database_id}/query", payload)

    def create_page(self, payload: dict[str, Any]) -> dict[str, Any]:
        return self.request("POST", "/pages", payload)

    def get_page(self, page_id: str) -> dict[str, Any]:
        return self.request("GET", f"/pages/{page_id}")

    def update_page(self, page_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        return self.request("PATCH", f"/pages/{page_id}", payload)

    def list_block_children(self, block_id: str, start_cursor: str | None = None) -> dict[str, Any]:
        suffix = f"?start_cursor={urllib.parse.quote(start_cursor)}" if start_cursor else ""
        return self.request("GET", f"/blocks/{block_id}/children{suffix}")

    def append_block_children(self, block_id: str, children: list[dict[str, Any]]) -> dict[str, Any]:
        return self.request("PATCH", f"/blocks/{block_id}/children", {"children": children})

    def delete_block(self, block_id: str) -> dict[str, Any]:
        return self.request("DELETE", f"/blocks/{block_id}")


def plain_text(text: str) -> list[dict[str, Any]]:
    return [{"type": "text", "text": {"content": text[:2000]}}] if text else []


def paragraph_block(text: str) -> dict[str, Any]:
    return {"object": "block", "type": "paragraph", "paragraph": {"rich_text": plain_text(text)}}


def heading_block(level: int, text: str) -> dict[str, Any]:
    block_type = f"heading_{min(max(level, 1), 3)}"
    return {"object": "block", "type": block_type, block_type: {"rich_text": plain_text(text)}}


def image_block(caption: str, url: str) -> dict[str, Any]:
    return {
        "object": "block",
        "type": "image",
        "image": {
            "type": "external",
            "external": {"url": url},
            "caption": plain_text(caption),
        },
    }


def callout_block(text: str, icon: str = "💡", color: str = "blue_background") -> dict[str, Any]:
    return {
        "object": "block",
        "type": "callout",
        "callout": {
            "rich_text": plain_text(text),
            "icon": {"type": "emoji", "emoji": icon},
            "color": color,
        },
    }


def code_block(language: str, code: str) -> dict[str, Any]:
    return {
        "object": "block",
        "type": "code",
        "code": {
            "rich_text": plain_text(code[:2000]),
            "language": language or "plain text",
        },
    }


def normalize_color(color: str | None) -> str:
    if not color:
        return "blue_background"
    return color.replace("_bg", "_background")


def markdown_to_blocks(markdown: str) -> list[dict[str, Any]]:
    blocks: list[dict[str, Any]] = []
    lines = markdown.splitlines()
    i = 0
    paragraph: list[str] = []

    def flush_paragraph() -> None:
        nonlocal paragraph
        if paragraph:
            blocks.append(paragraph_block(" ".join(part.strip() for part in paragraph).strip()))
            paragraph = []

    while i < len(lines):
        line = lines[i].rstrip()
        stripped = line.strip()

        if not stripped or stripped == "<empty-block/>":
            flush_paragraph()
            i += 1
            continue

        if stripped.startswith("<callout"):
            flush_paragraph()
            icon_match = re.search(r'icon="([^"]+)"', stripped)
            color_match = re.search(r'color="([^"]+)"', stripped)
            icon = icon_match.group(1) if icon_match else "💡"
            color = normalize_color(color_match.group(1) if color_match else None)
            collected: list[str] = []
            i += 1
            while i < len(lines) and "</callout>" not in lines[i]:
                content = lines[i].strip()
                if content:
                    collected.append(content)
                i += 1
            blocks.append(callout_block(" ".join(collected), icon=icon, color=color))
            i += 1
            continue

        if stripped.startswith("```"):
            flush_paragraph()
            language = stripped.removeprefix("```").strip() or "plain text"
            collected = []
            i += 1
            while i < len(lines) and not lines[i].strip().startswith("```"):
                collected.append(lines[i])
                i += 1
            blocks.append(code_block(language, "\n".join(collected)))
            i += 1
            continue

        image_match = re.match(r"!\[(.*?)\]\((.*?)\)", stripped)
        if image_match:
            flush_paragraph()
            blocks.append(image_block(image_match.group(1), image_match.group(2)))
            i += 1
            continue

        heading_match = re.match(r"^(#{1,4})\s+(.+)$", stripped)
        if heading_match:
            flush_paragraph()
            blocks.append(heading_block(len(heading_match.group(1)), heading_match.group(2)))
            i += 1
            continue

        bullet_match = re.match(r"^[-*]\s+(.+)$", stripped)
        if bullet_match:
            flush_paragraph()
            text = bullet_match.group(1)
            blocks.append({"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": plain_text(text)}})
            i += 1
            continue

        numbered_match = re.match(r"^\d+\.\s+(.+)$", stripped)
        if numbered_match:
            flush_paragraph()
            text = numbered_match.group(1)
            blocks.append({"object": "block", "type": "numbered_list_item", "numbered_list_item": {"rich_text": plain_text(text)}})
            i += 1
            continue

        paragraph.append(stripped)
        i += 1

    flush_paragraph()
    return blocks


def property_payload(prop: dict[str, Any], value: Any) -> dict[str, Any] | None:
    if value is None or value == "":
        return None
    prop_type = prop.get("type")
    if prop_type == "title":
        return {"title": plain_text(str(value))}
    if prop_type == "rich_text":
        return {"rich_text": plain_text(str(value))}
    if prop_type == "select":
        return {"select": {"name": str(value)}}
    if prop_type == "multi_select":
        if isinstance(value, str):
            items = [item.strip() for item in value.split(",") if item.strip()]
        else:
            items = [str(item) for item in value]
        return {"multi_select": [{"name": item} for item in items]}
    if prop_type == "date":
        return {"date": {"start": str(value)}}
    if prop_type == "url":
        return {"url": str(value)}
    if prop_type == "email":
        return {"email": str(value)}
    if prop_type == "phone_number":
        return {"phone_number": str(value)}
    if prop_type == "checkbox":
        return {"checkbox": bool(value)}
    return None


def build_properties(
    schema: dict[str, Any],
    args: argparse.Namespace,
    config: dict[str, Any],
    *,
    include_defaults: bool = True,
) -> dict[str, Any]:
    values = {
        "title": args.title,
        "status": args.status or (config.get("default_status", "Draft") if include_defaults else None),
        "type": args.type if args.type is not None else ("Post" if include_defaults else None),
        "category": args.category,
        "tags": args.tags,
        "slug": args.slug,
        "summary": args.summary,
        "date": args.date,
    }
    payload: dict[str, Any] = {}
    for name, prop in schema.items():
        if name in values:
            value = values[name]
            encoded = property_payload(prop, value)
            if encoded is not None:
                payload[name] = encoded
    return payload


def schema_from_page(client: NotionClient, page: dict[str, Any]) -> dict[str, Any]:
    parent = page.get("parent", {})
    parent_type = parent.get("type")
    if parent_type == "data_source_id":
        return client.get_data_source(parent["data_source_id"])["properties"]
    if parent_type == "database_id":
        return client.get_database(parent["database_id"])["properties"]
    return DEFAULT_SCHEMA


def append_children_in_chunks(client: NotionClient, block_id: str, children: list[dict[str, Any]]) -> None:
    for index in range(0, len(children), 100):
        client.append_block_children(block_id, children[index : index + 100])


def archive_existing_children(client: NotionClient, block_id: str) -> int:
    archived = 0
    cursor = None
    while True:
        result = client.list_block_children(block_id, cursor)
        for child in result.get("results", []):
            client.delete_block(child["id"])
            archived += 1
        if not result.get("has_more"):
            return archived
        cursor = result.get("next_cursor")


def resolve_parent_and_schema(
    client: NotionClient,
    database_or_url: str,
    data_source_id: str | None,
) -> tuple[dict[str, str], dict[str, Any]]:
    if data_source_id:
        resolved_data_source_id = parse_notion_id(data_source_id)
        data_source = client.get_data_source(resolved_data_source_id)
        return {"type": "data_source_id", "data_source_id": resolved_data_source_id}, data_source["properties"]

    if database_or_url.strip().startswith("collection://"):
        resolved_data_source_id = parse_notion_id(database_or_url)
        data_source = client.get_data_source(resolved_data_source_id)
        return {"type": "data_source_id", "data_source_id": resolved_data_source_id}, data_source["properties"]

    database_id = parse_notion_id(database_or_url)
    database = client.get_database(database_id)
    data_sources = database.get("data_sources") or []
    if data_sources:
        resolved_data_source_id = data_sources[0]["id"]
        data_source = client.get_data_source(resolved_data_source_id)
        return {"type": "data_source_id", "data_source_id": resolved_data_source_id}, data_source["properties"]

    # Legacy API fallback for older Notion-Version behavior.
    return {"database_id": database_id}, database["properties"]


def prompt_missing(args: argparse.Namespace, config: dict[str, Any]) -> None:
    if not args.database_id and not getattr(args, "data_source_id", None):
        suggested = config.get("last_database_id", "")
        prompt = "Notion database/data source ID or URL"
        if suggested:
            prompt += f" [{suggested}]"
        value = input(f"{prompt}: ").strip()
        args.database_id = value or suggested
    if not args.title:
        args.title = input("Title: ").strip()
    if not args.body_file and not args.body:
        print("Paste article Markdown. End with Ctrl-D:")
        args.body = sys.stdin.read()


def get_body(args: argparse.Namespace) -> str:
    if args.body_file:
        return Path(args.body_file).read_text(encoding="utf-8")
    body = args.body or ""
    if "\\n" in body and "\n" not in body:
        body = body.replace("\\n", "\n").replace("\\t", "\t")
    return body


def command_publish(args: argparse.Namespace) -> int:
    read_env_file()
    config = load_config()
    prompt_missing(args, config)

    target_input = args.data_source_id or args.database_id
    database_id = parse_notion_id(target_input)
    if args.save_database:
        config["last_database_id"] = database_id
        save_config(config)

    [REDACTED_SECRET] or os.environ.get("NOTION_TOKEN")
    if [REDACTED_SECRET] = NotionClient(token)
        parent, schema = resolve_parent_and_schema(client, args.database_id, args.data_source_id)
    elif args.dry_run:
        schema = DEFAULT_SCHEMA
        if args.data_source_id or target_input.strip().startswith("collection://"):
            parent = {"type": "data_source_id", "data_source_id": parse_notion_id(target_input)}
        else:
            parent = {"database_id": database_id}
    else:
        raise SystemExit("Missing NOTION_TOKEN. Set it in the environment or ~/.notion_publish/.env.")

    properties = build_properties(schema, args, config, include_defaults=True)
    body = get_body(args)
    children = markdown_to_blocks(body)
    if not children:
        children = [paragraph_block("")]

    payload: dict[str, Any] = {
        "parent": parent,
        "properties": properties,
        "children": children[:100],
    }
    if args.cover:
        payload["cover"] = {"type": "external", "external": {"url": args.cover}}

    if args.dry_run:
        print(json.dumps(payload, indent=2, ensure_ascii=False))
        return 0

    client = NotionClient(token)
    page = client.create_page(payload)
    print(json.dumps({"id": page["id"], "url": page.get("url")}, indent=2, ensure_ascii=False))
    return 0


def command_update(args: argparse.Namespace) -> int:
    read_env_file()
    config = load_config()
    [REDACTED_SECRET] or os.environ.get("NOTION_TOKEN")
    page_id = parse_notion_id(args.page_id)
    body = get_body(args)
    children = markdown_to_blocks(body) if body else []

    if args.dry_run:
        properties = build_properties(DEFAULT_SCHEMA, args, config, include_defaults=False)
        payload: dict[str, Any] = {}
        if properties:
            payload["properties"] = properties
        if args.cover:
            payload["cover"] = {"type": "external", "external": {"url": args.cover}}
        print(json.dumps({"page_id": page_id, "mode": args.mode, "page_update": payload, "children": children}, indent=2, ensure_ascii=False))
        return 0

    if not token:
        raise SystemExit("Missing NOTION_TOKEN. Set it in the environment or ~/.notion_publish/.env.")

    client = NotionClient(token)
    page = client.get_page(page_id)
    schema = schema_from_page(client, page)
    properties = build_properties(schema, args, config, include_defaults=False)

    page_payload: dict[str, Any] = {}
    if properties:
        page_payload["properties"] = properties
    if args.cover:
        page_payload["cover"] = {"type": "external", "external": {"url": args.cover}}
    if page_payload:
        client.update_page(page_id, page_payload)

    archived = 0
    if children:
        if args.mode == "replace":
            archived = archive_existing_children(client, page_id)
        append_children_in_chunks(client, page_id, children)

    print(json.dumps({"id": page_id, "url": page.get("url"), "mode": args.mode, "archived_children": archived, "appended_children": len(children)}, indent=2, ensure_ascii=False))
    return 0


def command_scan_drafts(args: argparse.Namespace) -> int:
    read_env_file()
    config = load_config()
    if not args.database_id and not args.data_source_id:
        args.database_id = input("Notion database/data source ID or URL: ").strip() or config.get("last_database_id", "")
    [REDACTED_SECRET] or os.environ.get("NOTION_TOKEN")
    if not token:
        raise SystemExit("Missing NOTION_TOKEN. Set it in the environment or ~/.notion_publish/.env.")
    client = NotionClient(token)
    parent, _schema = resolve_parent_and_schema(client, args.database_id or "", args.data_source_id)
    target_id = parent.get("data_source_id") or parent.get("database_id")
    client = NotionClient(token)
    if parent.get("data_source_id"):
        result = client.query_data_source(target_id, {"filter": {"property": "status", "select": {"equals": "Draft"}}, "page_size": 100})
    else:
        result = client.query_database(target_id, {"filter": {"property": "status", "select": {"equals": "Draft"}}, "page_size": 100})
    rows = []
    for page in result.get("results", []):
        props = page.get("properties", {})
        title_prop = props.get("title", {}).get("title", [])
        title = "".join(part.get("plain_text", "") for part in title_prop) or page["id"]
        rows.append({"id": page["id"], "title": title, "url": page.get("url")})
    print(json.dumps(rows, indent=2, ensure_ascii=False))
    return 0


def title_from_page(page: dict[str, Any]) -> str:
    props = page.get("properties", {})
    title_prop = props.get("title", {}).get("title", [])
    return "".join(part.get("plain_text", "") for part in title_prop) or page.get("id", "")


def simple_prop_value(prop: dict[str, Any]) -> Any:
    prop_type = prop.get("type")
    value = prop.get(prop_type, None)
    if prop_type == "title":
        return "".join(part.get("plain_text", "") for part in value or [])
    if prop_type == "rich_text":
        return "".join(part.get("plain_text", "") for part in value or [])
    if prop_type == "select":
        return (value or {}).get("name")
    if prop_type == "multi_select":
        return [item.get("name") for item in value or []]
    if prop_type == "date":
        return (value or {}).get("start")
    if prop_type in {"url", "email", "phone_number", "checkbox", "number"}:
        return value
    return None


def row_from_page(page: dict[str, Any]) -> dict[str, Any]:
    props = page.get("properties", {})
    return {
        "id": page.get("id"),
        "title": title_from_page(page),
        "status": simple_prop_value(props.get("status", {})),
        "type": simple_prop_value(props.get("type", {})),
        "category": simple_prop_value(props.get("category", {})),
        "slug": simple_prop_value(props.get("slug", {})),
        "date": simple_prop_value(props.get("date", {})),
        "url": page.get("url"),
    }


def page_matches_query(page: dict[str, Any], query: str) -> bool:
    if not query:
        return True
    needle = query.casefold()
    props = page.get("properties", {})
    haystack_parts = [title_from_page(page)]
    for name in ["summary", "slug", "status", "type", "category", "tags"]:
        value = simple_prop_value(props.get(name, {}))
        if isinstance(value, list):
            haystack_parts.extend(str(item) for item in value)
        elif value is not None:
            haystack_parts.append(str(value))
    return needle in " ".join(haystack_parts).casefold()


def build_search_filter(args: argparse.Namespace) -> dict[str, Any] | None:
    conditions = []
    if args.status:
        conditions.append({"property": "status", "select": {"equals": args.status}})
    if args.type:
        conditions.append({"property": "type", "select": {"equals": args.type}})
    if args.category:
        conditions.append({"property": "category", "select": {"equals": args.category}})
    if not conditions:
        return None
    if len(conditions) == 1:
        return conditions[0]
    return {"and": conditions}


def command_search(args: argparse.Namespace) -> int:
    read_env_file()
    config = load_config()
    if not args.database_id and not args.data_source_id:
        args.database_id = input("Notion database/data source ID or URL: ").strip() or config.get("last_database_id", "")
    [REDACTED_SECRET] or os.environ.get("NOTION_TOKEN")
    if not token:
        raise SystemExit("Missing NOTION_TOKEN. Set it in the environment or ~/.notion_publish/.env.")

    client = NotionClient(token)
    parent, _schema = resolve_parent_and_schema(client, args.database_id or "", args.data_source_id)
    target_id = parent.get("data_source_id") or parent.get("database_id")
    payload: dict[str, Any] = {"page_size": min(max(args.page_size, 1), 100)}
    filter_payload = build_search_filter(args)
    if filter_payload:
        payload["filter"] = filter_payload
    if args.sort_by_date:
        payload["sorts"] = [{"property": "date", "direction": "descending"}]

    rows = []
    cursor = None
    while True:
        request_payload = dict(payload)
        if cursor:
            request_payload["start_cursor"] = cursor
        if parent.get("data_source_id"):
            result = client.query_data_source(target_id, request_payload)
        else:
            result = client.query_database(target_id, request_payload)
        for page in result.get("results", []):
            if page_matches_query(page, args.query or ""):
                rows.append(row_from_page(page))
                if len(rows) >= args.limit:
                    print(json.dumps(rows, indent=2, ensure_ascii=False))
                    return 0
        if not result.get("has_more"):
            break
        cursor = result.get("next_cursor")

    print(json.dumps(rows, indent=2, ensure_ascii=False))
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Publish articles to Notion without MCP.")
    parser.add_argument("--token", help="Notion integration token. Defaults to NOTION_TOKEN.")
    subparsers = parser.add_subparsers(dest="command", required=True)

    publish = subparsers.add_parser("publish", help="Create a Notion database page.")
    publish.add_argument("--database-id", help="Notion database ID or URL. Prompted when omitted.")
    publish.add_argument("--data-source-id", help="Notion data source ID or collection:// URL. Overrides --database-id parent resolution.")
    publish.add_argument("--save-database", action="store_true", help="Save this database as a local prompt default.")
    publish.add_argument("--title", help="Article title. Prompted when omitted.")
    publish.add_argument("--body-file", help="Markdown file to publish.")
    publish.add_argument("--body", help="Markdown body string.")
    publish.add_argument("--status", help="Notion status option. Defaults to config default_status.")
    publish.add_argument("--type", default="Post", help="Notion type option.")
    publish.add_argument("--category", help="Notion category option.")
    publish.add_argument("--tags", help="Comma-separated Notion tag options.")
    publish.add_argument("--slug", help="Article slug.")
    publish.add_argument("--summary", help="Article summary.")
    publish.add_argument("--date", help="Publish date, YYYY-MM-DD.")
    publish.add_argument("--cover", help="External cover image URL.")
    publish.add_argument("--dry-run", action="store_true", help="Print the Notion API payload without creating a page.")
    publish.set_defaults(func=command_publish)

    update = subparsers.add_parser("update", help="Update an existing Notion page.")
    update.add_argument("--page-id", required=True, help="Notion page ID or URL.")
    update.add_argument("--mode", choices=["replace", "append"], default="replace", help="Replace existing content or append new content.")
    update.add_argument("--title", help="Article title.")
    update.add_argument("--body-file", help="Markdown file to write.")
    update.add_argument("--body", help="Markdown body string.")
    update.add_argument("--status", help="Notion status option.")
    update.add_argument("--type", help="Notion type option.")
    update.add_argument("--category", help="Notion category option.")
    update.add_argument("--tags", help="Comma-separated Notion tag options.")
    update.add_argument("--slug", help="Article slug.")
    update.add_argument("--summary", help="Article summary.")
    update.add_argument("--date", help="Publish date, YYYY-MM-DD.")
    update.add_argument("--cover", help="External cover image URL.")
    update.add_argument("--dry-run", action="store_true", help="Print the Notion API payload without updating the page.")
    update.set_defaults(func=command_update)

    scan = subparsers.add_parser("scan-drafts", help="List Draft pages in a database.")
    scan.add_argument("--database-id", help="Notion database ID or URL. Prompted when omitted.")
    scan.add_argument("--data-source-id", help="Notion data source ID or collection:// URL. Overrides --database-id parent resolution.")
    scan.set_defaults(func=command_scan_drafts)

    search = subparsers.add_parser("search", help="Search/list pages in a Notion database and return page IDs.")
    search.add_argument("--database-id", help="Notion database ID or URL. Prompted when omitted.")
    search.add_argument("--data-source-id", help="Notion data source ID or collection:// URL. Overrides --database-id parent resolution.")
    search.add_argument("--query", default="", help="Keyword filter applied locally to title, summary, slug, status, type, category, and tags.")
    search.add_argument("--status", help="Filter by status select option.")
    search.add_argument("--type", help="Filter by type select option.")
    search.add_argument("--category", help="Filter by category select option.")
    search.add_argument("--limit", type=int, default=25, help="Maximum matching rows to print.")
    search.add_argument("--page-size", type=int, default=100, help="Notion API query page size.")
    search.add_argument("--sort-by-date", action="store_true", help="Sort by date descending when the database has a date property.")
    search.set_defaults(func=command_search)
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        return args.func(args)
    except (NotionError, ValueError) as exc:
        print(str(exc), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


path: templates/catalog.md
content: ---
name: notion-publisher-template-catalog
description: Local article templates for the notion-publisher skill.
---

# Notion Publisher Template Catalog

These templates are original Notion-flavored Markdown layouts bundled with the skill. They are inspired by common public Notion template categories such as blog posts, editorial calendars, knowledge bases, and technical notes, but do not copy third-party template content.

When publishing an article, ask the user to choose one:

| ID | Name | File | Best for |
|---|---|---|---|
| 1 | Clean Essay | `templates/clean-essay.md` | 思考、观点、方法论文章 |
| 2 | Technical Deep Dive | `templates/technical-deep-dive.md` | 工具、代码、架构、实现原理 |
| 3 | Product Update | `templates/product-update.md` | 功能发布、版本更新、项目复盘 |
| 4 | Knowledge Note | `templates/knowledge-note.md` | 学习笔记、概念整理、教程 |
| 5 | Minimal Blog | `templates/minimal-blog.md` | 短文、随笔、轻量记录 |
| 6 | No Template | none | 直接写正文，不套模板 |

Selection guidance:
- For articles about a skill/tool implementation, default to Technical Deep Dive.
- For reflective writing, default to Clean Essay.
- For release notes or project announcements, default to Product Update.
- For tutorials or structured notes, default to Knowledge Note.
- For very short posts, default to Minimal Blog.

Placeholder convention:
- Replace `{{title}}`, `{{summary}}`, `{{intro}}`, `{{body}}`, `{{conclusion}}`, and other placeholders with generated article content.
- Remove any section that is not relevant to the article.
- Keep Notion-flavored Markdown valid: use tabs for child blocks inside callouts, toggles, and columns.


path: templates/clean-essay.md
content: <callout icon="✍️" color="blue_bg">
	**摘要**：{{summary}}
</callout>
# ✨ {{title}}
## 🌿 开场
{{intro}}
<empty-block/>
## 🧭 核心观点
{{body}}
<empty-block/>
## 💎 为什么这件事值得记录
- {{point_1}}
- {{point_2}}
- {{point_3}}
<empty-block/>
## 🧠 我的判断
{{analysis}}
<details color="gray_bg">
<summary>延伸思考</summary>
	- {{reflection_1}}
	- {{reflection_2}}
</details>
## 🌙 总结
{{conclusion}}


path: templates/knowledge-note.md
content: <callout icon="📚" color="yellow_bg">
	**速览**：{{summary}}
</callout>
# 📚 {{title}}
## 🎯 一句话解释
{{one_liner}}
<empty-block/>
## 🧩 关键概念
- **{{concept_1}}**：{{concept_1_desc}}
- **{{concept_2}}**：{{concept_2_desc}}
- **{{concept_3}}**：{{concept_3_desc}}
<empty-block/>
## 🪜 操作步骤
1. {{step_1}}
2. {{step_2}}
3. {{step_3}}
<empty-block/>
## 🧪 示例
```text
{{example}}
```
<details color="orange_bg">
<summary>容易踩坑</summary>
	- {{pitfall_1}}
	- {{pitfall_2}}
</details>
## ✅ 总结
{{conclusion}}


path: templates/minimal-blog.md
content: <callout icon="💡" color="purple_bg">
	{{summary}}
</callout>
# 💭 {{title}}
{{intro}}
<empty-block/>
## ⭐ 重点
- {{point_1}}
- {{point_2}}
- {{point_3}}
<empty-block/>
## 📝 记录
{{body}}
## 🌱 收尾
{{conclusion}}


path: templates/notion-defaults/catalog.md
content: ---
name: notion-publisher-notion-default-template-cache
description: Example cache registry for Notion database default templates.
---

# Cached Notion Default Templates

This file maps Notion databases or template IDs to local cached Markdown templates.

For public distribution, keep this file generic. Do not commit private database names, workspace URLs, database IDs, data source IDs, template IDs, or personal page URLs.

Example:

| Database | Database ID | Template | Template ID | Local file |
|---|---|---|---|---|
| Example Blog Database | `example-database-id` | Article Template | `example-template-id` | `templates/notion-defaults/example-article-template.md` |

Refresh policy:
- Prefer a local cached file for normal publishing.
- Fetch a Notion template only when the user asks to refresh/sync the cache, or when no matching local cache exists.
- If refreshed, overwrite the matching local file and update `cached_at`.


path: templates/notion-defaults/example-article-template.md
content: ---
source_database: Example Blog Database
source_database_id: example-database-id
source_data_source_id: example-data-source-id
source_template_name: Article Template
source_template_id: example-template-id
cached_at: 2026-01-01
usage: Example local cache for a Notion article template. Replace metadata with private values only in your local copy.
---

<callout icon="😀" color="gray_bg">
	**前言**：{{intro}}<br>
	{{story_hook}}
</callout>
<empty-block/>
# 📝 Main Idea
## 💡 I. {{section_1_title}}
> {{quote_1}}
{{point_1_body}}
<empty-block/>
## 🔍 II. {{section_2_title}}
> {{quote_2}}
{{point_2_body}}
<details color="blue_bg">
<summary>Extra notes</summary>
	- {{note_1}}
	- {{note_2}}
</details>
# 🤗 Summary
{{conclusion}}
# 📎 References
- {{reference_1}}
- {{reference_2}}
<empty-block/>
<callout icon="💡" color="gray_bg">
	{{closing_note}}
</callout>


path: templates/product-update.md
content: <callout icon="🚀" color="green_bg">
	**更新摘要**：{{summary}}
</callout>
# 🚀 {{title}}
## 🆕 这次更新了什么
{{overview}}
<empty-block/>
## ✨ 新能力
- {{feature_1}}
- {{feature_2}}
- {{feature_3}}
<empty-block/>
## 🪜 使用步骤
1. {{step_1}}
2. {{step_2}}
3. {{step_3}}
## 🎯 适用场景
{{scenarios}}
<details color="yellow_bg">
<summary>已知限制</summary>
	- {{limitation_1}}
	- {{limitation_2}}
</details>
## 🧭 下一步
{{next_steps}}


path: templates/technical-deep-dive.md
content: <callout icon="🧩" color="blue_bg">
	**TL;DR**：{{summary}}
</callout>
# 🧩 {{title}}
## 🔍 背景
{{background}}
<empty-block/>
## ⚙️ 使用方式
{{usage}}
<empty-block/>
## 🧭 工作流程
1. {{step_1}}
2. {{step_2}}
3. {{step_3}}
4. {{step_4}}
<empty-block/>
## 🏗️ 实现原理
{{implementation}}
<empty-block/>
## 🔧 关键适配点
- {{adaptation_1}}
- {{adaptation_2}}
- {{adaptation_3}}
<details color="gray_bg">
<summary>展开查看</summary>
	- {{note_1}}
	- {{note_2}}
	- {{note_3}}
</details>
## ✅ 总结
{{conclusion}}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)