- 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>
231 lines
9.0 KiB
Python
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("&", "&").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 += "<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()
|