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
This commit is contained in:
Kunthawat Greethong
2026-07-02 10:01:53 +07:00
commit 7b5af16f6d
36 changed files with 7803 additions and 0 deletions

View File

@@ -0,0 +1,635 @@
---
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 — แล้วจะเปลี่ยนยังไง?
![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):**
```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`