Files
moreminimore-service-system/skills/mm-publish-content/SKILL.md
Kunthawat Greethong 7b5af16f6d Initial commit: moreminimore-service content pipeline
- 9 mm* skills (orchestrator, article, social, publish, analytics)
- 6 dependency skills (content-writer, geo-optimizer, etc.)
- 3 analytics scripts (GSC, Google Ads, Meta Ads)
- Config template + setup guide
- SOUL-MM.md persona extension
- OrbitOS integration reference
2026-07-02 10:01:53 +07:00

18 KiB

name, description
name description
mm-publish-content Publish Stage: Publishes approved articles and social posts to multiple platforms. Supports Astro (git push), WordPress (REST API), Facebook (Page API), X/Twitter (xurl CLI), and Instagram (Graph API). Reads clients-config.json for credentials. Marks published status in frontmatter. Use when: "publish article", "publish บทความ", "ส่งบทความ", "ลงบทความ", "publish to astro", "publish to wordpress", "post to social".

mm-publish-content

Publishes approved content to websites and social media platforms.

When This Must Trigger

  • "publish article", "publish บทความ", "ส่งบทความ", "ลงบทความ"
  • "publish to astro", "publish to wordpress"
  • "post to facebook", "post to x", "post to instagram"
  • "ลง social", "publish social"

Prerequisites

  • Article must have status: approved in frontmatter
  • Social posts must exist (created by mm-content-writer)
  • Credentials must be configured in ~/vault/99_System/clients-config.json

Process

Step 1: Find content to publish

# List articles ready to publish
# Look for articles with status: approved in 60_Articles/<client-id>/
VAULT="$HOME/vault"

Search for articles with status: approved in per-client folders:

import os, re

vault = os.path.expanduser("~/vault/60_Articles")
ready = []

for root, dirs, files in os.walk(vault):
    for f in files:
        if f != 'article.md':
            continue
        path = os.path.join(root, f)
        content = open(path).read()
        if 'status: approved' in content[:500]:
            # Extract title and slug
            title_match = re.search(r'^title:\s*["\']?(.+?)["\']?\s*$', content, re.MULTILINE)
            title = title_match.group(1) if title_match else f
            slug = os.path.basename(root)
            ready.append({'path': path, 'title': title, 'slug': slug, 'dir': root})

If no approved articles found:

ไม่พบบทความที่พร้อม publish
บทความต้องมี status: approved ก่อน
ใช้ mm-content-writer skill เพื่อเขียนและอนุมัติบทความ

If multiple found, ask which one to publish (or publish all).

Step 2: Determine publish targets

Read the article's frontmatter:

  • related_client — look up in clients-config.json
  • published_to — already published platforms (skip these)

If related_client is set, load client config:

import json

config_path = os.path.expanduser("~/vault/99_System/clients-config.json")
config = json.load(open(config_path))

client = None
for c in config.get('clients', []):
    if c['id'] == related_client:
        client = c
        break

If no related_client or client not found:

บทความนี้ยังไม่ได้ระบุ client
ต้องการ publish ไปที่ไหน?

1. Astro website (specify repo)
2. WordPress website (specify URL)
3. Social media only (Facebook, X, IG)
4. ระบุ client จาก clients-config.json

If client is found, show available platforms:

📋 Client: [display_name]

🌐 Website:
  - [type]: [url/repo] ✅

📱 Social:
  - Facebook: [page_id] ✅/❌
  - Instagram: [user_id] ✅/❌
  - X: [username] ✅/❌

เผยแพร่ไปช่องทางไหนบ้าง? (เลือกหมายเลข หรือ "all"):
1. Website ([type])
2. Facebook
3. Instagram
4. X/Twitter
5. All available

Step 3: Publish to Website

3a. Astro (git push)

If website type is astro:

# 1. Clone or pull the repo
REPO_URL="<repo from config>"
BRANCH="<branch from config, default main>"
ARTICLE_DIR="<path to article>"

# 2. Convert article.md to Astro blog format
# - Move to src/content/blog/<slug>/index.md
# - Convert frontmatter to Astro format
# - Copy images to same directory
# - Update any wikilinks to regular links

# 3. Git commit and push
git add .
git commit -m "publish: <article title>"
git push origin $BRANCH

Conversion rules for Astro:

  • Keep title, description, pubDate, slug in frontmatter
  • Add heroImage pointing to featured image
  • Remove vault-specific fields (type, status, related_client)
  • Convert published_to to include astro
  • Copy images to the article directory

Capture published_url

After successful website publish, capture the URL:

For Astro: Construct from site config + slug

PUBLISHED_URL="<site_url>/blog/<slug>"

For WordPress: Get from API response

PUBLISHED_URL="<response.link>"

Save published_url to frontmatter

Update the article's frontmatter with the published URL:

import re

article_path = "<path to article.md>"
content = open(article_path).read()

# Add published_url after status: published
content = content.replace(
    "status: published",
    f"status: published\npublished_url: \"{published_url}\""
)

open(article_path, 'w').write(content)

Also update published_to to include the website platform:

# Add to published_to array
if "published_to: []" in content:
    content = content.replace("published_to: []", f"published_to: [astro]")

Confirm to user:

✅ บทความ published:
   URL: [published_url]
   Frontmatter updated: published_url, published_to

Update existing social posts with URL

If social posts already exist (from a previous run), update them with the published_url:

import os, re

article_dir = "<path to article dir>"
published_url = "<url>"

for platform in ['facebook', 'x', 'ig']:
    post_path = os.path.join(article_dir, f"post-{platform}.md")
    if not os.path.exists(post_path):
        continue
    
    content = open(post_path).read()
    
    # Add published_url to frontmatter
    if 'published_url' not in content:
        content = re.sub(
            r'^(related_article:.*)$',
            f'\\1\npublished_url: "{published_url}"',
            content,
            flags=re.MULTILINE
        )
    else:
        content = re.sub(
            r'^(published_url:\s*)["\']?.*?["\']?\s*$',
            f'\\1"{published_url}"',
            content,
            flags=re.MULTILINE
        )
    
    # Replace URL placeholders in body
    content = content.replace('[ARTICLE_URL]', published_url)
    content = content.replace('[article_url]', published_url)
    
    open(post_path, 'w').write(content)

Or use the helper script:

python3 ~/Gitea/moreminimore-service-system/scripts/mm_update_url.py \
  --dir ~/vault/60_Articles/<client-id>/<slug> \
  --url [published_url]

| meta_description | description | First 160 chars of description or article | | created | pubDate | Use source date, NOT today | | — | category | Look up from client config or ask user. Must match one of client's blog categories exactly | | — | heroImage | /images/blog/<slug>-<name>.png — the ASTRO path, not the vault path | | — | draft: false | Always add for publication | | type, status, related_client | (omit) | Strip vault-internal fields | | published_to | (omit from frontmatter) | Track separately |

Image relocation:

  • Vault featured: 60_Articles/<slug>/images/featured.png → Astro: public/images/blog/<slug>-featured.png
  • Vault inline: 60_Articles/<slug>/attachments/<name>.png → Astro: public/images/blog/<slug>-<name>.png
  • Every image reference in markdown: must rewrite vault path to Astro path

Example output:

---
title: "Fintech มองไม่เห็นโดย AI Agents — แล้วจะเปลี่ยนยังไง?"
description: "ธุรกิจการเงินพร้อมรับ AI Agents หรือยัง? วิเคราะห์ 3 แนวทางสร้าง visibility"
pubDate: 2026-06-26
category: "SEO"
heroImage: "/images/blog/2026-06-26-fintech-invisible-to-ai-agents-featured.png"
draft: false
---

# Fintech มองไม่เห็นโดย AI Agents — แล้วจะเปลี่ยนยังไง?

![AI Agent interaction diagram](/images/blog/2026-06-26-fintech-invisible-to-ai-agents-solution-diagram.png)

Path conventions:

  • Always use absolute paths from root: /images/blog/<slug>-<name>.png
  • Never use relative paths (images/... or ../images/...)
  • Prefix inline image names with the article slug to avoid collisions across articles

Post-conversion verification (MANDATORY):

# 1. Verify all Astro blog files exist
cd ~/Gitea/moreminimore-astroreal
ls src/content/blog/2026-*.md | wc -l

# 2. Verify all image references exist in public/
grep -oP '/images/blog/[^)\"\\s]+' src/content/blog/2026-*.md | sort -u | while IFS= read -r img; do
  [ -f "public$img" ] || echo "MISSING: public$img"
done

# 3. Build check
npm run build 2>&1 | tail -5
# Must show "[N] page(s) built" without errors

Git commit + push:

git add -A
git commit -m "feat: publish [article title]"
git push origin main

UPDATED clients-config.json: After publishing, update the client entry with:

  • website.categories — the full list of categories used (for content planning)
  • website.last_published — ISO date of latest publish batch
  • Keep website.repo pointing to the Astro repo path

3b. WordPress (REST API)

If website type is wordpress:

# WordPress REST API
WP_URL="<url from config>"
WP_USER="<user from config>"
WP_APP_PASSWORD="<password from config>"

# Create post
curl -X POST "$WP_URL/wp-json/wp/v2/posts" \
  -u "$WP_USER:$WP_APP_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "<title>",
    "content": "<html content>",
    "status": "draft",
    "slug": "<slug>",
    "categories": [<category_ids>],
    "featured_media": <media_id>
  }'

Steps:

  1. Convert markdown to WordPress HTML (handle headings, lists, images)
  2. Upload featured image to WordPress Media Library → get media_id
  3. Upload inline images → get URLs
  4. Create post with content + featured_media
  5. Set status to "draft" first (user can review in WP before publishing)

WordPress image upload:

curl -X POST "$WP_URL/wp-json/wp/v2/media" \
  -u "$WP_USER:$WP_APP_PASSWORD" \
  -H "Content-Disposition: attachment; filename=\"featured.png\"" \
  -H "Content-Type: image/png" \
  --data-binary @featured.png

Step 4: Publish to Social Media

4a. Facebook Page

Requires: page_id and page_token from client config.

# Post to Facebook Page
PAGE_ID="<from config>"
PAGE_TOKEN="<from config>"

# Text post
curl -X POST "https://graph.facebook.com/v21.0/$PAGE_ID/feed" \
  -d "message=<post content>" \
  -d "access_token=$PAGE_TOKEN"

# Post with image
# 1. Upload photo first
curl -X POST "https://graph.facebook.com/v21.0/$PAGE_ID/photos" \
  -F "source=@image.png" \
  -F "message=<caption>" \
  -F "access_token=$PAGE_TOKEN"

Read post-facebook.md from the article directory. Post content = body of post-facebook.md.

4b. X/Twitter (xurl CLI)

Requires: xurl configured with the client's app.

# Check xurl auth
xurl auth status

# Post single tweet
xurl post "<tweet content>"

# Post with image
xurl media upload images/featured.png
xurl post "<tweet content>" --media-id <MEDIA_ID>

# Post thread (if post-x.md is a thread)
xurl post "<tweet 1>"
# Get tweet ID from response
xurl reply <TWEET_ID> "<tweet 2>"
xurl reply <TWEET_ID_2> "<tweet 3>"

Read post-x.md from the article directory. Determine if single post or thread based on frontmatter format field.

4c. Instagram (Graph API)

Requires: user_id and token from client config (Facebook Graph API).

IG_USER_ID="<from config>"
IG_TOKEN="<from config>"

# 1. Create media container
CONTAINER_ID=$(curl -s -X POST "https://graph.facebook.com/v21.0/$IG_USER_ID/media" \
  -d "image_url=<public image url>" \
  -d "caption=<caption>" \
  -d "access_token=$IG_TOKEN" | jq -r '.id')

# 2. Publish container
curl -X POST "https://graph.facebook.com/v21.0/$IG_USER_ID/media_publish" \
  -d "creation_id=$CONTAINER_ID" \
  -d "access_token=$IG_TOKEN"

Important: Instagram requires images to be publicly accessible URLs. Options:

  1. Upload image to a public CDN first
  2. Use the WordPress media URL if already uploaded
  3. Use a temporary image hosting service

Read post-ig.md from the article directory.

Step 5: Update frontmatter

After successful publishing, update the article's frontmatter:

---
...existing...
status: published
published_to: [astro, facebook, x, ig]  # append new platforms
published_date: YYYY-MM-DD
---

Also update each social post's frontmatter:

---
...existing...
status: published
published_to: [facebook]
published_date: YYYY-MM-DD
post_id: "<platform post ID>"
---

Use patch to update frontmatter fields.

Step 6: Error handling

For each platform, if publishing fails:

❌ [Platform]: [error message]

สาเหตุที่เป็นไปได้:
- Token หมดอายุ → ต้อง renew
- credentials ไม่ถูกต้อง → ตรวจสอบ clients-config.json
- API quota หมด → รอและลองใหม่
- [platform-specific troubleshooting]

ต้องการ:
1. ข้าม platform นี้ → publish ช่องทางอื่นต่อ
2. หยุด → แก้ไขปัญหาก่อน
3. ลองใหม่

Step 7: Summary

CRITICAL: Full article verification. The user expects a per-article status report (not a summary). Generate a table showing EVERY article's status:

# Per-article verification
for f in src/content/blog/2026-*.md; do
  slug=$(basename "$f" .md)
  title=$(head -5 "$f" | grep "title:" | sed 's/title: "//' | sed 's/"$//')
  cat=$(grep "category:" "$f" | head -1 | sed 's/category: "//' | sed 's/"$//')
  hero=$(grep "heroImage:" "$f" | head -1)
  # Check if hero image exists
  if [ -n "$hero" ]; then
    img_path=$(echo "$hero" | grep -oP '/images/blog/[^"'"'"']+')
    [ -f "public$img_path" ] && img="✅" || img="❌ MISSING"
  else
    img="⚠️ no heroImage"
  fi
  echo "$slug | $title | $cat | $img"
done

Step 7: Summary

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Published: [Article Title]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🌐 Website:
  ✅ [Astro/WordPress]: [published_url]

📱 Social:
  ✅ Facebook: [post url or "posted"]
  ✅ X/Twitter: [tweet url or "posted"]
  ✅ Instagram: [posted]
  ❌ [Failed platform]: [reason]

📋 Status updated in vault frontmatter
📋 published_url saved: [url]

Step 8: Archive check (after all publishing)

After publishing, check if ALL content in the article directory is fully published. Only archive when the entire bundle is complete.

def can_archive(article_dir):
    """Check if ALL content in article dir is published"""
    import os
    
    # Files that must be checked
    required_files = ['article.md']
    optional_posts = ['post-facebook.md', 'post-x.md', 'post-ig.md']
    
    # Article must be published
    article_path = os.path.join(article_dir, 'article.md')
    if not os.path.exists(article_path):
        return False
    content = open(article_path).read()[:500]
    if 'status: published' not in content:
        return False
    
    # All existing posts must also be published
    for f in optional_posts:
        path = os.path.join(article_dir, f)
        if not os.path.exists(path):
            continue  # post doesn't exist = skip
        content = open(path).read()[:500]
        if 'status: published' not in content:
            return False
    
    return True

If can_archive() returns True:

✅ ทั้ง article และ posts publish ครบแล้ว — ย้ายไป Archive

ย้าย folder ไป Archive/? (yes/no)

If user confirms (or auto-confirm):

ARTICLE_DIR="<path>"
CLIENT_ID="<client-id>"
ARCHIVE_DIR="$HOME/vault/60_Articles/$CLIENT_ID/Archive"

mkdir -p "$ARCHIVE_DIR"
mv "$ARTICLE_DIR" "$ARCHIVE_DIR/$(basename $ARTICLE_DIR)"

If can_archive() returns False:

⚠️ ยังไม่ครบ — ไม่ย้ายไป Archive

สถานะ:
  ✅ article.md: published
  ✅ post-facebook.md: published
  ❌ post-x.md: draft (ยังไม่ publish)
  ✅ post-ig.md: published

ต้อง publish ทุกส่วนก่อนจึงจะย้ายไป Archive ได้

Important: NEVER move to Archive unless ALL existing content files are published. The archive represents "fully complete" work only.

Config Lookup

Credentials are read from ~/vault/99_System/clients-config.json.

Structure:

{
  "global": {
    "google": { ... },
    "meta": { ... }
  },
  "clients": [
    {
      "id": "client-id",
      "display_name": "Client Name",
      "website": {
        "type": "wordpress|astro",
        "url": "...",
        "wp_user": "...",
        "wp_app_password": "..."
      },
      "social": {
        "facebook": { "page_id": "...", "page_token": "..." },
        "instagram": { "user_id": "...", "token": "..." },
        "x": { "username": "...", "app": "..." }
      }
    }
  ]
}

If credentials are missing:

  1. Tell the user which credential is needed
  2. Explain how to obtain it
  3. Offer to save it to clients-config.json once provided

Notes

  • Always publish to website FIRST, then social (social posts should link to the article)
  • WordPress posts are created as "draft" by default — user reviews before publishing
  • Astro posts are pushed to git — may trigger CI/CD deployment
  • Instagram requires publicly accessible image URLs — plan accordingly
  • X/Twitter uses xurl CLI — must be installed and authenticated
  • Mark status: published only after ALL selected platforms succeed
  • If partial success (some platforms fail), mark only successful platforms in published_to