Files
felhom.eu/scripts/facebook/fb_probe.py
T
admin 3a77ed73aa
gates / gates (push) Successful in 5m58s
facebook: the first post drafted (COPY.md section 6) + fb_probe schedule-post
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.
2026-10-09 15:06:47 +02:00

625 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""fb_probe.py — the Facebook Page access probe (spike 2026-10-08). Stdlib only.
Usage: python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] read
python3 scripts/facebook/fb_probe.py [-v] [--version v26.0] [--evidence DIR] write-test
`read` Scenarios A-C: the system-user token debugs itself, reaches the Felhom.eu Page, derives
the Page token (memory only), reads the Page, its feed and a few insights metrics.
`write-test` Scenarios D-E: ONE scheduled text post and ONE scheduled photo, a week ahead, each read
back (UTF-8 hex compare) and DELETED; removal is proven by a failed GET, never by
DELETE's own success. Refuses unless A and B pass and the Page grants CREATE_CONTENT.
There is deliberately NO "post for real" sub-command: real posting belongs to the skill written
from this spike's findings (audits/SPIKE-facebook-page-api-2026-10-08.md).
SECRETS. The token comes ONLY from ~/.config/credentials (key FACEBOOK_API); no flag takes one.
It travels as `Authorization: Bearer`, never in a logged URL (the one query-string carrier,
debug_token's input_token, is never printed: -v prints the path without its query). Every saved
response passes redact(): every `access_token` key is dropped and any string carrying a held token
or an `EAA…` token shape is replaced. The Page token never touches disk.
Exit codes: 0 ok · 2 key refused · 3 Scenario A failed · 4 Scenario B failed · 5 write gate refused
· 6 a write was refused (stop, finding) · 7 a test object could not be proven removed
· 8 a test object read back PUBLISHED (deleted at once — read the report first)
"""
import argparse
import io
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, os.path.dirname(HERE))
from read_credential import CredentialError, unwrap # noqa: E402 (R-453: the one quote-stripper)
GRAPH = "https://graph.facebook.com"
APP_ID = "2273465403490709"
PAGE_NAME = "Felhom.eu"
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. "
"Ez a bejegyzés törlődik.")
HEADERS_KEPT = ("facebook-api-version", "x-business-use-case-usage", "x-app-usage", "x-page-usage",
"x-fb-trace-id", "x-fb-rev")
TOKEN_SHAPE = re.compile(r"EAA[A-Za-z0-9]{10,}")
VERBOSE = False
SECRETS = [] # every token value held this run; redact() scrubs each
def log(msg):
print(msg, flush=True)
def vlog(msg):
if VERBOSE:
print(" " + msg, flush=True)
# ---------------------------------------------------------------- the key (R-453)
def load_key(path, key=KEY):
"""Return the value of `key`. Accepts an optional `export ` prefix; ONE matching quote pair is
stripped by read_credential.unwrap. Then asserts: no quote, no whitespace, starts with EAA."""
with open(path, encoding="utf-8") as fh:
for line in fh:
s = line.strip()
if s.startswith("export "):
s = s[len("export "):].lstrip()
if s.startswith(key + "="):
value = unwrap(s[len(key) + 1:].strip())
if any(q in value for q in ("'", '"')):
raise CredentialError("value still contains a quote character")
if any(c.isspace() for c in value):
raise CredentialError("value contains whitespace")
if not value.startswith("EAA"):
raise CredentialError("value does not start with EAA (starts %r)" % value[:3])
return value
raise CredentialError("key %r not present in %s" % (key, path))
# ---------------------------------------------------------------- redaction
def redact(obj, secrets=None):
"""Deep copy of `obj` with every access_token key removed and every token-bearing string replaced."""
secrets = SECRETS if secrets is None else secrets
if isinstance(obj, dict):
return {k: redact(v, secrets) for k, v in obj.items() if k != "access_token"}
if isinstance(obj, list):
return [redact(v, secrets) for v in obj]
if isinstance(obj, str):
if any(s and s in obj for s in secrets) or TOKEN_SHAPE.search(obj):
return "[REDACTED]"
return obj
# ---------------------------------------------------------------- HTTP
class Call:
def __init__(self, step, method, path, status, body, headers, err):
self.step, self.method, self.path = step, method, path
self.status, self.body, self.headers, self.err = status, body, headers, err
@property
def ok(self):
return self.err is None
class Graph:
def __init__(self, version, evidence):
self.version, self.evidence = version, evidence
self.headers_seen = {}
os.makedirs(evidence, exist_ok=True)
def call(self, step, method, path, token, query=None, form=None, files=None):
"""One Graph call. `path` is printed and saved; `query` is sent but NEVER printed or saved
when it carries a secret (it is saved only as the list of its keys)."""
url = "%s/%s/%s" % (GRAPH, self.version, path.lstrip("/"))
if query:
url += "?" + urllib.parse.urlencode(query)
data, ctype = None, None
if files:
data, ctype = multipart(form or {}, files)
elif form is not None:
data, ctype = urllib.parse.urlencode(form).encode("utf-8"), "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
if ctype:
req.add_header("Content-Type", ctype)
status, raw, hdrs = None, b"", {}
try:
with urllib.request.urlopen(req, timeout=60) as r:
status, raw, hdrs = r.status, r.read(), r.headers
except urllib.error.HTTPError as e:
status, raw, hdrs = e.code, e.read(), e.headers
except urllib.error.URLError as e:
status, raw = None, json.dumps({"transport_error": str(e.reason)}).encode()
try:
body = json.loads(raw.decode("utf-8")) if raw else {}
except ValueError:
body = {"non_json_body": raw[:500].decode("utf-8", "replace")}
kept = {h: hdrs.get(h) for h in HEADERS_KEPT if hdrs and hdrs.get(h) is not None}
for h, v in kept.items():
self.headers_seen.setdefault(h, v)
err = None
if status is None or not (200 <= status < 300):
err = body.get("error", body) if isinstance(body, dict) else body
elif isinstance(body, dict) and "error" in body: # Meta can say 200 and mean no (§9.3)
err = body["error"]
c = Call(step, method, path, status, body, kept, err)
vlog("%s %s -> HTTP %s%s" % (method, path, status,
"" if err is None else " ERROR " + json.dumps(redact(err), ensure_ascii=False)))
self.save(c, query, form, files)
return c
def save(self, c, query, form, files):
rec = {
"step": c.step, "method": c.method, "path": c.path, "api_version": self.version,
"query_keys": sorted(query) if query else [],
"form": redact({k: v for k, v in (form or {}).items()}),
"files": {k: os.path.relpath(v, ROOT) for k, v in (files or {}).items()},
"http_status": c.status, "headers": c.headers, "ok": c.ok,
"error": c.err, "response": c.body,
"at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
path = os.path.join(self.evidence, c.step + ".json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(redact(rec), fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
def multipart(fields, files):
boundary = "----felhomprobe" + uuid.uuid4().hex
out = []
for k, v in fields.items():
out += [b"--" + boundary.encode(), ('Content-Disposition: form-data; name="%s"' % k).encode(), b"",
str(v).encode("utf-8")]
for k, p in files.items():
with open(p, "rb") as fh:
blob = fh.read()
out += [b"--" + boundary.encode(),
('Content-Disposition: form-data; name="%s"; filename="%s"' % (k, os.path.basename(p))).encode(),
b"Content-Type: image/png", b"", blob]
out += [b"--" + boundary.encode() + b"--", b""]
return b"\r\n".join(out), "multipart/form-data; boundary=" + boundary
def note(g, step, data):
"""A non-call record (a verdict, a comparison) saved beside the calls."""
with open(os.path.join(g.evidence, step + ".json"), "w", encoding="utf-8") as fh:
json.dump(redact(data), fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
# ---------------------------------------------------------------- scenarios
def scenario_a(g, token):
c = g.call("A1-debug-token", "GET", "debug_token", token, query={"input_token": token})
d = (c.body or {}).get("data", {}) if c.ok else {}
checks = {"call_ok": c.ok, "is_valid": d.get("is_valid") is True, "app_id": d.get("app_id") == APP_ID}
log("A debug_token: ok=%s is_valid=%s type=%s app_id=%s expires_at=%s data_access_expires_at=%s"
% (c.ok, d.get("is_valid"), d.get("type"), d.get("app_id"), d.get("expires_at"),
d.get("data_access_expires_at")))
log(" scopes=%s" % d.get("scopes"))
return all(checks.values()), d
def scenario_b(g, token):
me = g.call("B1-me", "GET", "me", token, query={"fields": "id,name"})
log("B /me: ok=%s id=%s name=%s" % (me.ok, me.body.get("id"), me.body.get("name")))
acc = g.call("B2-me-accounts", "GET", "me/accounts", token, query={"fields": "id,name,tasks,access_token"})
if not (me.ok and acc.ok):
return None
pages = acc.body.get("data", [])
log(" /me/accounts: %d page(s): %s" % (len(pages), [p.get("name") for p in pages]))
mine = [p for p in pages if p.get("name") == PAGE_NAME]
if len(mine) != 1:
log(" STOP: expected exactly one page named %s, found %d" % (PAGE_NAME, len(mine)))
return None
p = mine[0]
ptok = p.get("access_token")
if not ptok:
log(" STOP: the page entry carries no access_token")
return None
SECRETS.append(ptok)
log(" page id=%s tasks=%s (page token held in memory, %d chars)" % (p["id"], p.get("tasks"), len(ptok)))
dbg = g.call("B3-debug-page-token", "GET", "debug_token", token, query={"input_token": ptok})
dd = dbg.body.get("data", {}) if dbg.ok else {}
log(" page token: ok=%s type=%s is_valid=%s expires_at=%s data_access_expires_at=%s scopes=%s"
% (dbg.ok, dd.get("type"), dd.get("is_valid"), dd.get("expires_at"),
dd.get("data_access_expires_at"), dd.get("scopes")))
return {"id": p["id"], "tasks": p.get("tasks") or [], "token": ptok}
INSIGHT_METRICS = ("page_post_engagements", "page_follows", "page_media_view")
# The details box, one field per call. ONE PER CALL ON PURPOSE: Graph fails a whole `fields=` list when
# any single member is unreadable, so a batch would let one refused field hide the other five (the same
# reason INSIGHT_METRICS is called one at a time). A refusal is recorded with Meta's error VERBATIM and
# never retried — a retry would turn „Meta refuses this field" into „the field is empty", which is a
# different fact. An absent key is also not „empty": `null` and „not returned" are logged apart.
DETAIL_FIELDS = ("emails", "phone", "category_list", "location", "single_line_address", "hours")
def scenario_c(g, page):
pid, ptok = page["id"], page["token"]
c1 = g.call("C1-page", "GET", pid, ptok,
query={"fields": "id,name,link,category,about,website,followers_count,fan_count"})
log("C page fields: ok=%s %s" % (c1.ok, {k: v for k, v in c1.body.items() if k != "error"} if c1.ok else c1.err))
for f in DETAIL_FIELDS:
c = g.call("C4-detail-" + f, "GET", pid, ptok, query={"fields": f})
if not c.ok:
log(" detail %-20s REFUSED %s" % (f, json.dumps(c.err, ensure_ascii=False)))
elif f not in c.body:
log(" detail %-20s ok, but the field was NOT RETURNED (not the same as null)" % f)
else:
log(" detail %-20s = %s" % (f, json.dumps(c.body[f], ensure_ascii=False)))
c2 = g.call("C2-feed", "GET", pid + "/feed", ptok, query={"limit": "5"})
log(" feed: ok=%s posts=%s" % (c2.ok, len(c2.body.get("data", [])) if c2.ok else c2.err))
for m in INSIGHT_METRICS: # one call per metric: one renamed metric must not hide the others
c = g.call("C3-insights-" + m, "GET", pid + "/insights", ptok, query={"metric": m, "period": "day"})
log(" insights %s: ok=%s %s" % (m, c.ok, "values=%d" % len(c.body.get("data", [])) if c.ok
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()
def gone_error(err):
"""True when a GET's error says the object does not exist. MEASURED 2026-10-08 (v26.0, Page token): a deleted
scheduled post answers code 10 „(#10) Object does not exist, cannot be loaded due to missing permission…", not
code 100. Code 10 also means a missing permission, so callers count it only after the SAME token read the object
successfully before the DELETE (the readback is the control)."""
if not isinstance(err, dict):
return False
msg = err.get("message") or ""
return err.get("code") == 100 or (err.get("code") == 10 and "Object does not exist" in msg)
def prove_removed(g, step, obj_id, ptok):
c = g.call(step, "GET", obj_id, ptok, query={"fields": "id"})
gone = (not c.ok) and gone_error(c.err)
log(" GET after DELETE %s: HTTP %s, removed=%s, error=%s"
% (obj_id, c.status, gone, json.dumps(c.err, ensure_ascii=False)))
return gone
def delete(g, step, obj_id, ptok):
c = g.call(step, "DELETE", obj_id, ptok)
if not (c.ok and c.body.get("success") is True):
log(" DELETE %s failed (%s) — once more after 30 s" % (obj_id, c.err))
time.sleep(30)
c = g.call(step + "-retry", "DELETE", obj_id, ptok)
log(" DELETE %s: ok=%s body=%s" % (obj_id, c.ok, c.body))
return c.ok and c.body.get("success") is True
def check_readback(label, rb, sent_hex, field, when):
got = rb.body.get(field) if rb.ok else None
res = {
"readback_ok": rb.ok, "is_published": rb.body.get("is_published") if rb.ok else None,
"scheduled_publish_time_sent": when,
"scheduled_publish_time_read": rb.body.get("scheduled_publish_time") if rb.ok else None,
"text_field": field, "sent_hex": sent_hex, "read_hex": hexs(got) if got is not None else None,
}
res["hex_equal"] = res["read_hex"] == sent_hex
log(" %s read back: is_published=%s scheduled=%s (sent %s) hex_equal=%s"
% (label, res["is_published"], res["scheduled_publish_time_read"], when, res["hex_equal"]))
return res
def scenario_d(g, page, rc):
pid, ptok = page["id"], page["token"]
when = int(time.time()) + WEEK_S
c = g.call("D1-create-feed", "POST", pid + "/feed", ptok,
form={"message": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)})
if not c.ok:
log("D REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False))
return 6, None
post_id = c.body.get("id")
log("D created %s" % post_id)
rb = g.call("D2-readback", "GET", post_id, ptok,
query={"fields": "message,is_published,scheduled_publish_time,created_time"})
res = check_readback("D", rb, hexs(TEST_TEXT), "message", when)
res["post_id"] = post_id
published = res["is_published"] is True
deleted = delete(g, "D3-delete", post_id, ptok)
res["delete_ok"] = deleted
res["removed"] = prove_removed(g, "D4-get-after-delete", post_id, ptok)
note(g, "D9-verdict", res)
if published:
log("D !!! the post read back PUBLISHED — deleted; removed=%s" % res["removed"])
return 8, res
if not res["removed"]:
log("D !!! REMOVAL NOT PROVEN for %s — remove it by hand in Meta Business Suite > Planner" % post_id)
return 7, res
return rc, res
def scenario_e(g, page, rc):
pid, ptok = page["id"], page["token"]
when = int(time.time()) + WEEK_S
c = g.call("E1-create-photo", "POST", pid + "/photos", ptok,
form={"caption": TEST_TEXT, "published": "false", "scheduled_publish_time": str(when)},
files={"source": LOGO})
if not c.ok:
log("E REFUSED: %s — stopping all writes" % json.dumps(c.err, ensure_ascii=False))
return 6, None
photo_id, post_id = c.body.get("id"), c.body.get("post_id")
log("E created photo id=%s post_id=%s (keys returned: %s)" % (photo_id, post_id, sorted(c.body)))
res = {"photo_id": photo_id, "post_id": post_id, "create_keys": sorted(c.body), "logo": os.path.relpath(LOGO, ROOT)}
if post_id:
rb = g.call("E2-readback-post", "GET", post_id, ptok,
query={"fields": "message,is_published,scheduled_publish_time,created_time"})
res["post"] = check_readback("E post", rb, hexs(TEST_TEXT), "message", when)
if photo_id:
rp = g.call("E3-readback-photo", "GET", photo_id, ptok, query={"fields": "id,name,created_time,link"})
got = rp.body.get("name") if rp.ok else None
res["photo"] = {"readback_ok": rp.ok, "name_hex": hexs(got) if got is not None else None}
res["photo"]["hex_equal"] = res["photo"]["name_hex"] == hexs(TEST_TEXT)
log(" E photo read back: ok=%s caption hex_equal=%s" % (rp.ok, res["photo"]["hex_equal"]))
published = bool(res.get("post", {}).get("is_published"))
# Delete the post first, then look at both ids: which object DELETE needs is a finding (§7 E).
first = post_id or photo_id
res["delete_first_target"] = "post_id" if post_id else "photo_id"
res["delete_first_ok"] = delete(g, "E4-delete-" + res["delete_first_target"], first, ptok)
res["post_removed"] = prove_removed(g, "E5-get-post-after-delete", post_id, ptok) if post_id else None
res["photo_removed"] = prove_removed(g, "E6-get-photo-after-delete", photo_id, ptok) if photo_id else None
if photo_id and post_id and not res["photo_removed"]:
log(" the photo outlived its post's DELETE — deleting the photo id too")
res["delete_photo_ok"] = delete(g, "E7-delete-photo_id", photo_id, ptok)
res["photo_removed"] = prove_removed(g, "E8-get-photo-after-delete", photo_id, ptok)
note(g, "E9-verdict", res)
removed = res["photo_removed"] is not False and res["post_removed"] is not False
if published:
log("E !!! the photo post read back PUBLISHED — deleted; removed=%s" % removed)
return 8, res
if not removed:
log("E !!! REMOVAL NOT PROVEN (photo %s, post %s) — remove by hand in Meta Business Suite > Planner"
% (photo_id, post_id))
return 7, res
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):
global VERBOSE
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("-v", action="store_true", help="print each call: method, path (no query), status, error")
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", "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:
token = load_key(a.credentials)
except (CredentialError, OSError) as e:
log("KEY REFUSED [%s]: %s" % (KEY, e))
return 2
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
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, 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, a=None):
ok_a, _ = scenario_a(g, token)
if not ok_a:
log("A FAILED")
return 3
page = scenario_b(g, token)
if page is None:
log("B FAILED")
return 4
if cmd == "read":
scenario_c(g, page)
log("read: done")
return 0
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
rc, _ = scenario_e(g, page, 0)
log("write-test: done rc=%d" % rc)
return rc
if __name__ == "__main__":
sys.exit(main())