- 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
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: approvedin 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.jsonpublished_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,slugin frontmatter - Add
heroImagepointing to featured image - Remove vault-specific fields (
type,status,related_client) - Convert
published_toto includeastro - 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 — แล้วจะเปลี่ยนยังไง?

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.repopointing 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:
- Convert markdown to WordPress HTML (handle headings, lists, images)
- Upload featured image to WordPress Media Library → get media_id
- Upload inline images → get URLs
- Create post with content + featured_media
- 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:
- Upload image to a public CDN first
- Use the WordPress media URL if already uploaded
- 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:
- Tell the user which credential is needed
- Explain how to obtain it
- 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: publishedonly after ALL selected platforms succeed - If partial success (some platforms fail), mark only successful platforms in
published_to