Files
claudetools/.claude/skills/forum-post/scripts/flarum-post.py
Mike Swanson 9581d87589 harness: scratch graduation pipeline (push side + spec) + flarum first test case
- graduation-push.sh: tar+scp scratch -> BEAST graduation-inbox over Tailscale (decoupled
  from /save, soft-fail if BEAST off). Tested: 241 files -> BEAST.
- docs/graduation-pipeline.md: full spec (push -> Ollama triage on BEAST GPU via API ->
  reviewed sanitize+git-mv). Secrets never enter git; ride the encrypted link to BEAST only.
- tmp-promotion-check.sh: rewritten pure-builtin (0.4s) after the per-file grep/fork loop
  hung /save for 4 min on Windows at ~240 scratch files. Deep triage moves to the pipeline.
- forum-post: GRADUATED the canonical flarum poster from scratch ->
  skills/forum-post/scripts/flarum-post.py (s9e markdown->XML + DB insert machinery), with
  the hardcoded IX SSH + Flarum DB passwords swapped to vault lookups. First pipeline test case.
- Vaulted the Flarum DB cred (services/flarum-community.sops.yaml) + sanitized the two
  plaintext copies in forum-post.md.
- errorlog: logged the WSL-stub correction + BEAST-Ollama-CPU(vram=0) finding + the
  promotion-check hang, all via the new log helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:55:48 -07:00

231 lines
9.0 KiB
Python

#!/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 `<meta name="robots" content="noindex,follow">` 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 `<meta name="robots" content="noindex">` in the `<head>`."""
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 += "<STRONG><s>**</s>" + inline_to_xml(text[i + 2:end]) + "<e>**</e></STRONG>"
i = end + 2
continue
if text[i] == "`":
end = text.find("`", i + 1)
if end != -1:
result += "<C><s>`</s>" + xml_escape(text[i + 1:end]) + "<e>`</e></C>"
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 += "<EM><s>*</s>" + xml_escape(text[i + 1:end]) + "<e>*</e></EM>"
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("<H2><s>## </s>" + inline_to_xml(line[3:]) + "</H2>")
i += 1
elif line.startswith("### "):
elements.append("<H3><s>### </s>" + inline_to_xml(line[4:]) + "</H3>")
i += 1
elif line.startswith("- "):
items = []
while i < len(lines) and lines[i].startswith("- "):
items.append("<LI><s>- </s>" + inline_to_xml(lines[i][2:]) + "</LI>")
i += 1
elements.append("<LIST>" + "\n".join(items) + "</LIST>")
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("<LI><s>" + m.group(1) + ". </s>" + inline_to_xml(m.group(2)) + "</LI>")
i += 1
elements.append('<LIST type="decimal">' + "\n".join(items) + "</LIST>")
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'<CODE lang="{lang}"><s>```{lang}</s><i>\n</i>' + code_body + "\n<e>```</e></CODE>")
else:
elements.append("<CODE><s>```</s><i>\n</i>" + code_body + "\n<e>```</e></CODE>")
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("<p>" + inline_to_xml("\n".join(para_lines)) + "</p>")
return "<r>" + "\n\n".join(elements) + "</r>"
xml_content = md_to_s9e(CONTENT_MD)
print(f"[INFO] XML length={len(xml_content)}")
php_template = """<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
$dsn = 'mysql:host=localhost;dbname=azcompu_flarum;charset=utf8mb4';
$pdo = new PDO($dsn, 'azcompu_flarum', '%%DB_PASS%%', [PDO::ATTR_ERRMODE => 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()