facebook: the first post drafted (COPY.md section 6) + fb_probe schedule-post
gates / gates (push) Successful in 5m58s

DRAFTS. COPY.md section 6: two versions of the Page's first post, shortened
from section 2. Hungarian, tegezo, no price. 6.1 = 347 characters, 6.2 = 638
(counted as Unicode characters). Every claim carries a source comment naming
the line of website/index.html it rests on; the first line of each carries the
point alone, because Facebook cuts after about three lines.

Deliberate: ZERO emoji and ZERO hashtags, though the brief allows two of each.
The Felhom design system uses no emoji (the website gate holds it at 0) and
two hashtags would serve no real search.

CHECKED, because the post repeats it: "56 alkalmazas" is CORRECT. The apps
page carries 57 <div class="app-card"> but states 56, which looks off by one
until you read the category line -- "6 alkalmazas + 1 beepitett". The 57th
card is FileBrowser, built into every box and deliberately not counted.
index.html says "56 telepitheto alkalmazas" too. I nearly "fixed" a live page
into being wrong. NOT-A-FINDING.

SCHEDULE-POST. A third sub-command on fb_probe.py, reusing its token loader
(R-453), redaction, Bearer call and evidence writer:

  - the body is READ FROM COPY.md by section name. The Hungarian never passes
    through a shell or an argv string (brief 9.6); the caller names a section.
  - published is ALWAYS "false" and NO argument can change it (brief 9.7).
    The operator's review in Planner is the safety net, so an immediate post
    must be unreachable, not merely not-the-default.
  - check_when refuses a time under Meta's 10-minute floor or over its
    6-month ceiling, BEFORE the call, so a bad time is a readable local
    refusal rather than a Graph error.
  - budapest_to_epoch uses the real tz database. If zoneinfo has no
    Europe/Budapest it REFUSES rather than falling back to a hardcoded
    +01:00/+02:00 -- guessing the offset is how a post goes out an hour wrong
    across a DST boundary.
  - list_scheduled tries /scheduled_posts then feed?is_published=false and
    RECORDS WHICH ANSWERED; when both are refused it returns None so the
    caller says "unproven" instead of claiming a removal it never saw.

TESTS: 30, of which 2 skip on Windows (no tzdata in this interpreter; the
command runs on the Linux host, which has the system zoneinfo).

RED-PROOF of the guard that matters, as the brief requires. Made
schedule_form accept published=..., ran ScheduleForm, and watched
test_no_argument_can_publish_immediately FAIL:
    AssertionError: 'true' != 'false' : published changed published
Guard restored, all 30 green again.

No post has been made. The dry check and the real post come next; the
operator picks the version and the time first.
This commit is contained in:
2026-10-09 15:06:45 +02:00
parent a05345eeb6
commit 3a77ed73aa
3 changed files with 338 additions and 4 deletions
+178 -4
View File
@@ -25,6 +25,7 @@ Exit codes: 0 ok · 2 key refused · 3 Scenario A failed · 4 Scenario B failed
· 8 a test object read back PUBLISHED (deleted at once — read the report first)
"""
import argparse
import io
import json
import os
import re
@@ -47,6 +48,13 @@ KEY = "FACEBOOK_API"
DEFAULT_EVIDENCE = os.path.join(ROOT, "documentation", "audits", "facebook-page-api-2026-10-08")
LOGO = os.path.join(ROOT, "website", "assets", "logo.png")
WEEK_S = 7 * 24 * 3600
# Meta refuses a scheduled_publish_time outside this window. 10 minutes is Meta's own floor;
# the 6-month ceiling is its documented maximum. Both are enforced BEFORE the call, so a bad
# time is a local refusal with a readable reason rather than a Graph error.
SCHED_MIN_S = 10 * 60
SCHED_MAX_S = 180 * 24 * 3600
COPY_MD = os.path.join(ROOT, "marketing", "facebook", "COPY.md")
POST_LINK = "https://felhom.eu/"
# "Felhom teszt – árvíztűrő tükörfúrógép. Ez a bejegyzés törlődik." — built from escapes, never typed
# through a shell (brief §9.8).
TEST_TEXT = ("Felhom teszt – árvíztűrő tükörfúrógép. "
@@ -274,6 +282,25 @@ def scenario_c(g, page):
else json.dumps(c.err, ensure_ascii=False)))
def budapest_to_epoch(text):
"""'YYYY-MM-DD HH:MM' in Europe/Budapest -> Unix seconds. Uses the real tz database, so the
summer/winter offset is never guessed."""
from datetime import datetime
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover - python < 3.9
raise ValueError("zoneinfo is unavailable; cannot convert a Budapest wall-clock time safely")
try:
tz = ZoneInfo("Europe/Budapest")
except Exception as e:
# No silent fallback to a hardcoded +01:00/+02:00: guessing the offset is how a post goes
# out an hour early or late across a DST boundary. Refuse and say what is missing.
raise ValueError("no Europe/Budapest in the tz database (%s); install tzdata or run this on "
"the Linux host, where the system zoneinfo is present" % e)
dt = datetime.strptime(text.strip(), "%Y-%m-%d %H:%M").replace(tzinfo=tz)
return int(dt.timestamp())
def hexs(s):
return (s or "").encode("utf-8").hex()
@@ -394,6 +421,126 @@ def scenario_e(g, page, rc):
return rc, res
# ------------------------------------------------- schedule-post (R-917, the first post)
def read_copy_section(path, want):
"""Return the fenced block under '### <want>' in COPY.md, as text.
The Hungarian never passes through a shell or an argv string (brief §9.6): the post body is
READ FROM THE FILE and sent as UTF-8 bytes. The caller names a section, not a message.
"""
with io.open(path, encoding="utf-8") as fh:
t = fh.read()
marker = "### " + want
i = t.find(marker)
if i < 0:
raise ValueError("no section %r in %s" % (want, path))
j = t.find("```", i)
if j < 0:
raise ValueError("section %r has no fenced block" % want)
j = t.index("\n", j) + 1
k = t.find("```", j)
if k < 0:
raise ValueError("section %r has an unclosed fenced block" % want)
return t[j:k].rstrip("\n")
def check_when(when, now=None):
"""Refuse a scheduled time outside Meta's window. Returns the time; raises ValueError."""
now = int(time.time()) if now is None else now
delta = int(when) - now
if delta < SCHED_MIN_S:
raise ValueError("scheduled_publish_time is %d s away; Meta's floor is %d s (10 minutes)"
% (delta, SCHED_MIN_S))
if delta > SCHED_MAX_S:
raise ValueError("scheduled_publish_time is %d s away; Meta's ceiling is %d s (6 months)"
% (delta, SCHED_MAX_S))
return int(when)
def schedule_form(message, when, link=POST_LINK):
"""The form for ONE scheduled post. published is ALWAYS 'false' and no argument can change it
(brief §9.7): the operator's review in Planner is the safety net, so this command cannot
publish immediately even by mistake."""
form = {"message": message, "published": "false", "scheduled_publish_time": str(check_when(when))}
if link:
form["link"] = link
return form
def list_scheduled(g, step, pid, ptok):
"""The Page's scheduled posts. Two routes are tried and WHICH ONE ANSWERED IS RECORDED —
/scheduled_posts is the documented edge; the is_published=false feed filter is the fallback.
Returns (ids, route) or (None, None) when both are refused, so the caller can say 'unproven'
instead of claiming a removal it did not see."""
c = g.call(step, "GET", pid + "/scheduled_posts", ptok, query={"fields": "id,is_published", "limit": "50"})
if c.ok and isinstance(c.body.get("data"), list):
return [d.get("id") for d in c.body["data"]], "scheduled_posts"
c2 = g.call(step + "-feedfallback", "GET", pid + "/feed", ptok,
query={"fields": "id,is_published", "limit": "50", "is_published": "false"})
if c2.ok and isinstance(c2.body.get("data"), list):
return [d.get("id") for d in c2.body["data"]], "feed?is_published=false"
log(" scheduled-post LIST refused on both routes: %s | %s"
% (json.dumps(c.err, ensure_ascii=False), json.dumps(c2.err, ensure_ascii=False)))
return None, None
def scenario_sched_dry(g, page):
"""Scenario A: prove the link parameter is safe on a scheduled post, and prove REMOVAL from the
list rather than from an error after DELETE."""
pid, ptok = page["id"], page["token"]
when = int(time.time()) + WEEK_S
c = g.call("S1-dry-create", "POST", pid + "/feed", ptok, form=schedule_form(TEST_TEXT, when))
if not c.ok:
log("S link+schedule REFUSED: %s" % json.dumps(c.err, ensure_ascii=False))
return 6, {"created": False, "error": c.err}
post_id = c.body.get("id")
log("S created %s (scheduled, with link)" % post_id)
rb = g.call("S2-dry-readback", "GET", post_id, ptok,
query={"fields": "message,is_published,scheduled_publish_time,permalink_url"})
res = check_readback("S", rb, hexs(TEST_TEXT), "message", when)
res["post_id"] = post_id
published = res["is_published"] is True
before, route = list_scheduled(g, "S3-list-before", pid, ptok)
res["list_route"] = route
res["present_before_delete"] = (post_id in before) if before is not None else None
log(" list route=%s present=%s" % (route, res["present_before_delete"]))
res["delete_ok"] = delete(g, "S4-dry-delete", post_id, ptok)
after, _ = list_scheduled(g, "S5-list-after", pid, ptok)
res["absent_after_delete"] = (post_id not in after) if after is not None else None
log(" after delete: absent=%s" % res["absent_after_delete"])
note(g, "S9-dry-verdict", res)
if published:
log("S !!! the scheduled post read back PUBLISHED — deleted at once")
return 8, res
return 0, res
def scenario_sched_post(g, page, message, when):
pid, ptok = page["id"], page["token"]
c = g.call("P1-create", "POST", pid + "/feed", ptok, form=schedule_form(message, when))
if not c.ok:
log("P REFUSED: %s" % json.dumps(c.err, ensure_ascii=False))
return 6, {"created": False, "error": c.err}
post_id = c.body.get("id")
log("P scheduled %s" % post_id)
rb = g.call("P2-readback", "GET", post_id, ptok,
query={"fields": "message,is_published,scheduled_publish_time,permalink_url,created_time"})
res = check_readback("P", rb, hexs(message), "message", when)
res["post_id"] = post_id
res["permalink_url"] = rb.body.get("permalink_url") if rb.ok else None
ids, route = list_scheduled(g, "P3-list", pid, ptok)
res["list_route"] = route
res["in_scheduled_list"] = (post_id in ids) if ids is not None else None
log(" in the scheduled list (%s): %s" % (route, res["in_scheduled_list"]))
if res["is_published"] is True:
log("P !!! read back PUBLISHED — this command must never publish; NOT deleting, tell the operator")
note(g, "P9-verdict", res)
return 8, res
note(g, "P9-verdict", res)
return 0, res
# ---------------------------------------------------------------- main
def main(argv=None):
@@ -403,7 +550,13 @@ def main(argv=None):
ap.add_argument("--version", default="v26.0", help="Graph API version (default v26.0)")
ap.add_argument("--evidence", default=DEFAULT_EVIDENCE)
ap.add_argument("--credentials", default=os.path.expanduser("~/.config/credentials"))
ap.add_argument("cmd", choices=("read", "write-test"))
ap.add_argument("cmd", choices=("read", "write-test", "schedule-post"))
ap.add_argument("--section", default="6.1",
help="COPY.md section to post, e.g. 6.1 or 6.2 (schedule-post)")
ap.add_argument("--at", default=None,
help="when to publish, 'YYYY-MM-DD HH:MM' in Europe/Budapest (schedule-post)")
ap.add_argument("--dry", action="store_true",
help="schedule-post: the throwaway link+schedule check, then delete it")
a = ap.parse_args(argv)
VERBOSE = a.v
try:
@@ -414,15 +567,16 @@ def main(argv=None):
SECRETS.append(token)
log("key %s: %d chars, starts %s" % (KEY, len(token), token[:3]))
# write-test re-derives A and B into its own sub-directory, so the read run's files stay as they were
g = Graph(a.version, a.evidence if a.cmd == "read" else os.path.join(a.evidence, "write-test"))
sub = {"read": "", "write-test": "write-test"}.get(a.cmd, "first-post")
g = Graph(a.version, a.evidence if not sub else os.path.join(a.evidence, sub))
try:
return run(a.cmd, g, token)
return run(a.cmd, g, token, a)
finally:
note(g, "F1-headers", g.headers_seen) # §7 F: recorded once each, on every exit path
log("F headers: %s" % json.dumps(g.headers_seen))
def run(cmd, g, token):
def run(cmd, g, token, a=None):
ok_a, _ = scenario_a(g, token)
if not ok_a:
log("A FAILED")
@@ -438,6 +592,26 @@ def run(cmd, g, token):
if "CREATE_CONTENT" not in page["tasks"]:
log("WRITE GATE: the page's tasks lack CREATE_CONTENT (%s) — no write" % page["tasks"])
return 5
if cmd == "schedule-post":
if a.dry:
rc, _ = scenario_sched_dry(g, page)
log("schedule-post --dry: done (rc=%d)" % rc)
return rc
if not a.at:
log("schedule-post needs --at 'YYYY-MM-DD HH:MM' (Europe/Budapest)")
return 2
try:
when = budapest_to_epoch(a.at)
check_when(when)
message = read_copy_section(COPY_MD, a.section)
except (ValueError, OSError) as e:
log("REFUSED: %s" % e)
return 2
log("P section %s: %d characters, publishing at %s (epoch %d)"
% (a.section, len(message), a.at, when))
rc, _ = scenario_sched_post(g, page, message, when)
log("schedule-post: done (rc=%d)" % rc)
return rc
rc, _ = scenario_d(g, page, 0)
if rc != 0:
return rc