import difflib import html import os import re import subprocess import sys import urllib.parse BANNER_STYLE = "background:#f4f4f0;border-left:4px solid #999;padding:0.6em 1em;font-style:italic" BANNER_TEXT = ("This is a Just the Facts (JTF) post: generated by AI from my own raw material " "(voice memos, session notes) and a short outline I wrote, then checked by me.") NOTES_MARKER = "===NOTES===" BODY_START = "" BODY_END = "" REVISE_INSTRUCTIONS = ("CURRENT DRAFT: revise this draft rather than writing a new one. Change only what the " "outline, corrections and guardrails now require, and keep every other sentence exactly as " "it is. In the notes, list each change you made.") BOX_STYLE = "border:1px solid #ccc;padding:0.4em 1em;margin:1em 0" TRACKING_PARAMS = {"ref", "ref_", "fbclid", "gclid", "mc_cid", "mc_eid", "crid", "dib", "dib_tag", "qid", "sprefix", "sr", "keywords"} def section(outline, name): match = re.search(r"(?ms)^\* " + name + r"\n(.*?)(?=^\* |\Z)", outline) return match.group(1).strip("\n") if match else "" def header(outline, pattern): match = re.search(pattern, outline, re.M) return match.group(1).strip() if match else "" def source_text(post_dir, link): path = os.path.join(post_dir, os.path.expanduser(link)) with open(path) as f: return "".join(line for line in f if not line.startswith("#+")) def clean_url(url): parts = urllib.parse.urlsplit(url) asin = re.search(r"/(?:dp|gp/product)/([A-Z0-9]{10})", parts.path) if "amazon." in parts.netloc and asin: return f"https://{parts.netloc}/dp/{asin.group(1)}" query = [(k, v) for k, v in urllib.parse.parse_qsl(parts.query) if k not in TRACKING_PARAMS and not k.startswith("utm_")] return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query), fragment=parts.fragment)) def resource_entries(outline): entries = [] for line in section(outline, "Resources").splitlines(): line = line.strip().lstrip("-").strip() if not line: continue match = re.match(r"\[\[([^\]]+)\](?:\[([^\]]+)\])?\]", line) target, label = (match.group(1), match.group(2) or "") if match else (line.split()[0], "") entries.append((target, label)) return entries def is_text(path): try: with open(path, encoding="utf-8") as f: f.read() return True except UnicodeDecodeError: return False def upload(post_dir, path): local = os.path.join(post_dir, os.path.expanduser(path)) if not os.path.isfile(local): sys.exit(f"Resource file not found: {local}") key = f"jtf/{os.path.basename(os.path.abspath(post_dir))}/{os.path.basename(local)}" content_type = ["--content-type", "text/plain; charset=utf-8"] if is_text(local) else [] subprocess.run(["aws", "s3", "cp", "--profile", os.environ["JTF_AWS_PROFILE"], "--quiet", *content_type, local, f"{os.environ['JTF_S3_BUCKET']}/{key}"], check=True) return f"{os.environ['JTF_FILES_BASE']}/{key}" def label_urls(urls): if not urls: return {} ask = ("For each URL below, give a short, human-readable link label (the title of the book, " "product, article or page, plus the site in parentheses when useful), judging only from " "the URL itself. Output one line per URL, exactly: URL|label\n\n" + "\n".join(urls)) out = subprocess.run(["claude", "-p"], input=ask, capture_output=True, text=True, check=True).stdout labels = {} for line in out.splitlines(): url, _, label = line.strip().rpartition("|") if url in urls and label: labels[url] = label.strip() return labels def resources(post_dir, outline): links = [] for target, label in resource_entries(outline): if target.startswith("file:"): path = target[len("file:"):] links.append((upload(post_dir, path), target, label or os.path.basename(path))) else: links.append((clean_url(target), target, label)) labels = label_urls([original for url, original, label in links if not label]) return [(url, label or labels.get(original) or urllib.parse.urlsplit(url).netloc) for url, original, label in links] def resources_box(links): if not links: return "" items = "".join(f'
{text}
' def details(summary, text): return (f"{html.escape(text)}\n]*>(.*?)", f.read(), re.S) return html.unescape(match.group(1)) if match else "" def outline_changes(post_path, outline): before = embedded_outline(post_path) if not before: return "OUTLINE CHANGES: unknown (this draft doesn't record the outline it was written from)." diff = list(difflib.unified_diff(before.splitlines(), section(outline, "Outline").splitlines(), "outline the draft was written from", "current outline", lineterm="", n=1)) if not diff: return "OUTLINE CHANGES: none since this draft was written." return ("OUTLINE CHANGES since this draft was written. Apply every one of them to the draft, " "including small wording changes (- lines were removed, + lines added):\n" + "\n".join(diff)) def generated_body(post_dir, outline, prompt, links, draft=None): request = claude_input(post_dir, outline, prompt, links) if draft is not None: changes = outline_changes(os.path.join(post_dir, "post.html"), outline) request += f"\n\n{changes}\n\n{REVISE_INSTRUCTIONS}\n{draft}" result = subprocess.run(["claude", "-p"], input=request, capture_output=True, text=True, check=True) body, _, notes = result.stdout.partition(NOTES_MARKER) with open(os.path.join(post_dir, "notes.txt"), "w") as f: f.write(notes.strip() + "\n") return body.strip() def main(post_dir, prompt_path, blog_id, explainer_url, mode="generate"): with open(os.path.join(post_dir, "outline.org")) as f: outline = f.read() with open(prompt_path) as f: prompt = f.read().strip() is_explainer = header(outline, r"^:EXPLAINER:\s*(\S+)") == "t" post_path = os.path.join(post_dir, "post.html") links = resources(post_dir, outline) if mode == "keep": body = existing_body(post_path) elif mode == "revise": body = generated_body(post_dir, outline, prompt, links, draft=existing_body(post_path)) else: body = generated_body(post_dir, outline, prompt, links) headers = "\n".join([ f"", f"", f"", f"", "", ]) with open(post_path, "w") as f: f.write("\n\n".join(part for part in [headers, banner(is_explainer, explainer_url), images_block(outline), f"{BODY_START}\n{body}\n{BODY_END}", gallery_block(outline), resources_box(links), behind_this_post(outline)] if part)) print(f"{post_path}|{len(body.splitlines())}|{os.path.join(post_dir, 'notes.txt')}") if __name__ == "__main__": main(*sys.argv[1:6])