- 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
636 lines
18 KiB
Markdown
636 lines
18 KiB
Markdown
---
|
|
name: mm-publish-content
|
|
description: >
|
|
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
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
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:
|
|
```bash
|
|
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:**
|
|
```markdown
|
|
---
|
|
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):**
|
|
```bash
|
|
# 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:**
|
|
```bash
|
|
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`:
|
|
|
|
```bash
|
|
# 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:
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
```bash
|
|
# 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).
|
|
|
|
```bash
|
|
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:
|
|
|
|
```yaml
|
|
---
|
|
...existing...
|
|
status: published
|
|
published_to: [astro, facebook, x, ig] # append new platforms
|
|
published_date: YYYY-MM-DD
|
|
---
|
|
```
|
|
|
|
Also update each social post's frontmatter:
|
|
|
|
```yaml
|
|
---
|
|
...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:
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
```python
|
|
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):
|
|
|
|
```bash
|
|
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:
|
|
```json
|
|
{
|
|
"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`
|