#!/usr/bin/env python3 """flarum-post.py — post a markdown article to community.azcomputerguru.com (Flarum) by converting it to Flarum's s9e TextFormatter XML and inserting into the Flarum MySQL DB over SSH to the IX server. The canonical machinery behind the /forum-post skill. GRADUATED 2026-06-15 from .claude/tmp scratch (the first test case of the scratch-graduation pipeline — see .claude/docs/graduation-pipeline.md). Secrets that were HARDCODED in the scratch original (IX root SSH password, Flarum DB password) now load from the SOPS vault at runtime, so this file is safe to commit. NEXT STEP (Mike's plans): genericize — take TITLE / CONTENT_MD / SLUG / tag_id as inputs (skill Phase 1) instead of the hardcoded demo article below. The converter + insert flow are already generic; only the CONTENT_MD/TITLE/SLUG block is example data. """ import os, re, subprocess import paramiko # ---- secrets from vault (never hardcode) ---------------------------------------------------- def _root(): return os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) def vault_get(path, field): r = _root() p = subprocess.run(["bash", os.path.join(r, ".claude", "scripts", "vault.sh"), "get-field", path, field], capture_output=True, text=True, timeout=30) v = (p.stdout or "").strip() if not v: raise SystemExit(f"[ERROR] vault_get {path} {field} failed: {(p.stderr or '').strip()[:200]}") return v HOST = "172.16.3.10" SSH_USER = "root" SSH_PASS = vault_get("infrastructure/ix-server.sops.yaml", "credentials.password") DB_PASS = vault_get("services/flarum-community.sops.yaml", "credentials.db_password") # ---- DEMO article (replace with skill Phase 1 inputs when genericizing) --------------------- CONTENT_MD = """\ We did a server-wide audit today and found 7 production WordPress sites with search indexing silently disabled. The sites looked completely normal to visitors. Google couldn't see any of them. Here's the setting, why it gets left on, and how to audit a whole cPanel server at once. ## The Setting In WordPress: **Settings -> Reading -> "Discourage search engines from indexing this site"** When checked, WordPress adds `` to every page and sets `blog_public` to `0` in `wp_options`. When unchecked, `blog_public = 1`. One row in one table; no other indicator anywhere on the site. ## The Fix One SQL update per site: ```sql UPDATE wp_options SET option_value = '1' WHERE option_name = 'blog_public'; ``` After updating, verify by fetching the page source and confirming there's no `` in the ``.""" TITLE = 'WordPress "Discourage Search Engines" Setting -- How 7 Production Sites Lost Their Indexing' SLUG = "wordpress-discourage-search-engines-setting-how-7-production-sites-lost-their-indexing" TAG_ID = 7 # How-Tos & Tips # ---- markdown -> Flarum s9e TextFormatter XML ----------------------------------------------- def xml_escape(t): return t.replace("&", "&").replace("<", "<").replace(">", ">") def inline_to_xml(text): result = "" i = 0 while i < len(text): if text[i:i + 2] == "**": end = text.find("**", i + 2) if end != -1: result += "**" + inline_to_xml(text[i + 2:end]) + "**" i = end + 2 continue if text[i] == "`": end = text.find("`", i + 1) if end != -1: result += "`" + xml_escape(text[i + 1:end]) + "`" i = end + 1 continue if text[i] == "*" and text[i:i + 2] != "**": j = i + 1 end = -1 while j < len(text): if text[j] == "*" and text[j:j + 2] != "**": end = j break j += 1 if end != -1: result += "*" + xml_escape(text[i + 1:end]) + "*" i = end + 1 continue result += xml_escape(text[i]) i += 1 return result def md_to_s9e(md): lines = md.split("\n") elements = [] i = 0 while i < len(lines): line = lines[i] if not line.strip(): i += 1 continue if line.startswith("## "): elements.append("

## " + inline_to_xml(line[3:]) + "

") i += 1 elif line.startswith("### "): elements.append("

### " + inline_to_xml(line[4:]) + "

") i += 1 elif line.startswith("- "): items = [] while i < len(lines) and lines[i].startswith("- "): items.append("
  • - " + inline_to_xml(lines[i][2:]) + "
  • ") i += 1 elements.append("" + "\n".join(items) + "") elif re.match(r"^\d+\. ", line): items = [] while i < len(lines) and re.match(r"^\d+\. ", lines[i]): m = re.match(r"^(\d+)\. (.*)", lines[i]) items.append("
  • " + m.group(1) + ". " + inline_to_xml(m.group(2)) + "
  • ") i += 1 elements.append('' + "\n".join(items) + "") elif line.startswith("```"): lang = line[3:].strip() code_lines = [] i += 1 while i < len(lines) and not lines[i].startswith("```"): code_lines.append(xml_escape(lines[i])) i += 1 i += 1 code_body = "\n".join(code_lines) if lang: elements.append(f'```{lang}\n' + code_body + "\n```") else: elements.append("```\n" + code_body + "\n```") else: para_lines = [] while i < len(lines) and lines[i].strip(): l = lines[i] if (l.startswith("## ") or l.startswith("### ") or l.startswith("- ") or l.startswith("```") or re.match(r"^\d+\. ", l)): break para_lines.append(l) i += 1 elements.append("

    " + inline_to_xml("\n".join(para_lines)) + "

    ") return "" + "\n\n".join(elements) + "" xml_content = md_to_s9e(CONTENT_MD) print(f"[INFO] XML length={len(xml_content)}") php_template = """ PDO::ERRMODE_EXCEPTION]); $user_id = 1; $tag_id = %%TAG_ID%%; $title = %%TITLE_JSON%%; $slug = '%%SLUG%%'; $now = date('Y-m-d H:i:s'); $content = <<<'FLARUM_POST_XML_END' %%XML_CONTENT%% FLARUM_POST_XML_END; $stmt = $pdo->prepare("INSERT INTO discussions (title, comment_count, post_number_index, created_at, user_id, slug, is_private, is_approved) VALUES (?, 1, 1, ?, ?, ?, 0, 1)"); $stmt->execute([$title, $now, $user_id, $slug]); $disc_id = $pdo->lastInsertId(); echo "Discussion ID: $disc_id\\n"; $stmt = $pdo->prepare("INSERT INTO posts (discussion_id, number, created_at, user_id, type, content, is_private, is_approved) VALUES (?, 1, ?, ?, 'comment', ?, 0, 1)"); $stmt->execute([$disc_id, $now, $user_id, $content]); $post_id = $pdo->lastInsertId(); echo "Post ID: $post_id\\n"; $pdo->prepare("UPDATE discussions SET first_post_id=?, last_post_id=?, last_posted_at=?, last_posted_user_id=?, last_post_number=1 WHERE id=?")->execute([$post_id, $post_id, $now, $user_id, $disc_id]); $pdo->prepare("INSERT INTO discussion_tag (discussion_id, tag_id) VALUES (?, ?)")->execute([$disc_id, $tag_id]); echo "Done! URL: https://community.azcomputerguru.com/d/$disc_id-$slug\\n"; """ import json as _json php_script = (php_template .replace("%%XML_CONTENT%%", xml_content) .replace("%%DB_PASS%%", DB_PASS) .replace("%%TAG_ID%%", str(TAG_ID)) .replace("%%SLUG%%", SLUG) .replace("%%TITLE_JSON%%", _json.dumps(TITLE))) client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect(HOST, username=SSH_USER, password=SSH_PASS, timeout=10) print("[OK] SSH connected") sftp = client.open_sftp() with sftp.open("/tmp/flarum_post.php", "wb") as f: f.write(php_script.encode("utf-8")) sftp.close() def run_chan(cmd): chan = client.get_transport().open_session() chan.exec_command(cmd) chan.shutdown_write() out = b"" while not chan.exit_status_ready(): if chan.recv_ready(): out += chan.recv(4096) while chan.recv_ready(): out += chan.recv(4096) return out.decode("utf-8", errors="replace"), chan.recv_exit_status() out, rc = run_chan("php -l /tmp/flarum_post.php 2>&1") print(f"Syntax: {out.strip()}") out, rc = run_chan("php /tmp/flarum_post.php 2>&1") print(f"rc={rc}\n{out}") run_chan("rm -f /tmp/flarum_post.php") client.close()