@topy-ai/maggie 0.7.53 → 0.7.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +28 -50
- package/README.md +80 -64
- package/bin/maggie.js +61 -9
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +15 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +37 -2
- package/bundled-skills/maggie-deployment/SKILL.md +104 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +50 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +147 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +84 -1
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +223 -2
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +49 -0
- package/package.json +1 -1
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import sys
|
|
8
|
-
from datetime import datetime, timezone
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
10
11
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
11
13
|
|
|
12
14
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
15
|
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
|
|
@@ -43,6 +45,66 @@ def tracked_url(url: str, channel: str) -> str:
|
|
|
43
45
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
# Only a definite "gone" answer drops an article. Blocked (403/429), failing
|
|
49
|
+
# (5xx) or unreachable pages are kept: a firewall or an outage must never empty
|
|
50
|
+
# the list of articles to share.
|
|
51
|
+
GONE = {404, 410}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_status(url: str, timeout: float = 10.0) -> int | None:
|
|
55
|
+
"""Final HTTP status after redirects, or None when the page could not be reached."""
|
|
56
|
+
for method in ("HEAD", "GET"):
|
|
57
|
+
request = Request(url, method=method, headers={"User-Agent": "MaggieSocialShare/1.0 (+link check)", "Accept": "text/html,*/*"})
|
|
58
|
+
try:
|
|
59
|
+
with urlopen(request, timeout=timeout) as response:
|
|
60
|
+
return int(response.status)
|
|
61
|
+
except HTTPError as error:
|
|
62
|
+
# Some servers refuse HEAD; ask again with GET before deciding.
|
|
63
|
+
if method == "HEAD" and error.code in {400, 403, 405, 501}:
|
|
64
|
+
continue
|
|
65
|
+
return int(error.code)
|
|
66
|
+
except (URLError, TimeoutError, OSError):
|
|
67
|
+
return None
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def link_verdict(status: int | None) -> str:
|
|
72
|
+
if status in GONE:
|
|
73
|
+
return "gone"
|
|
74
|
+
if status is not None and 200 <= status < 400:
|
|
75
|
+
return "live"
|
|
76
|
+
return "unknown"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_links(urls: list[str], cache_path: Path | None, ttl_hours: float, now: datetime, fetch=fetch_status) -> dict:
|
|
80
|
+
cache: dict = {}
|
|
81
|
+
if cache_path and cache_path.is_file():
|
|
82
|
+
try:
|
|
83
|
+
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
84
|
+
except (OSError, json.JSONDecodeError):
|
|
85
|
+
cache = {}
|
|
86
|
+
results = []
|
|
87
|
+
for url in dict.fromkeys(urls):
|
|
88
|
+
cached = cache.get(url)
|
|
89
|
+
fresh = False
|
|
90
|
+
if isinstance(cached, dict) and cached.get("checked_at"):
|
|
91
|
+
try:
|
|
92
|
+
fresh = (now - parse_instant(cached["checked_at"], "checked_at")).total_seconds() < ttl_hours * 3600
|
|
93
|
+
except ValueError:
|
|
94
|
+
fresh = False
|
|
95
|
+
if fresh:
|
|
96
|
+
status, source = cached.get("status"), "cache"
|
|
97
|
+
else:
|
|
98
|
+
status, source = fetch(url), "fetched"
|
|
99
|
+
cache[url] = {"status": status, "checked_at": now.isoformat().replace("+00:00", "Z")}
|
|
100
|
+
results.append({"url": url, "status": status, "verdict": link_verdict(status), "source": source})
|
|
101
|
+
if cache_path:
|
|
102
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
cache_path.write_text(json.dumps(cache, indent=2) + "\n", encoding="utf-8")
|
|
104
|
+
gone = [item for item in results if item["verdict"] == "gone"]
|
|
105
|
+
return {"valid": True, "checked": len(results), "skip": gone, "keep": [item for item in results if item["verdict"] != "gone"], "results": results}
|
|
106
|
+
|
|
107
|
+
|
|
46
108
|
def plan(content: dict, channels: list[str], workspace_domain: str | None = None) -> dict:
|
|
47
109
|
errors = [f"missing {key}" for key in REQUIRED if not content.get(key)]
|
|
48
110
|
invalid = [channel for channel in channels if channel not in LIMITS]
|
|
@@ -73,6 +135,130 @@ def plan(content: dict, channels: list[str], workspace_domain: str | None = None
|
|
|
73
135
|
}
|
|
74
136
|
|
|
75
137
|
|
|
138
|
+
def shared_picture_errors(posts: list[dict], label: str) -> list[str]:
|
|
139
|
+
"""Posts written together for several channels share one picture.
|
|
140
|
+
|
|
141
|
+
A channel may differ only when a person picked its picture
|
|
142
|
+
(`image_source: "user"`)."""
|
|
143
|
+
automatic = [post for post in posts if post.get("image_source") != "user"]
|
|
144
|
+
pictures = {post.get("image_url") or None for post in automatic}
|
|
145
|
+
if len(pictures) <= 1:
|
|
146
|
+
return []
|
|
147
|
+
by_picture: dict[str, list[str]] = {}
|
|
148
|
+
for post in automatic:
|
|
149
|
+
by_picture.setdefault(post.get("image_url") or "(none)", []).append(str(post.get("channel") or post.get("name") or post.get("id")))
|
|
150
|
+
detail = "; ".join(f"{', '.join(names)}: {picture}" for picture, names in by_picture.items())
|
|
151
|
+
return [f"{label}: channels written together must share one picture ({detail}); set image_source user on a channel whose picture a person chose"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def picture_groups(value: dict) -> dict:
|
|
155
|
+
"""Check that drafts written from one idea share one picture across channels."""
|
|
156
|
+
drafts = value.get("drafts", []) if isinstance(value, dict) else value
|
|
157
|
+
groups: dict[str, list[dict]] = {}
|
|
158
|
+
for draft in drafts:
|
|
159
|
+
key = draft.get("idea_id") or draft.get("group_id")
|
|
160
|
+
if key:
|
|
161
|
+
groups.setdefault(str(key), []).append(draft)
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
report = []
|
|
164
|
+
for key, items in sorted(groups.items()):
|
|
165
|
+
problems = shared_picture_errors(items, f"idea {key}")
|
|
166
|
+
errors.extend(problems)
|
|
167
|
+
automatic = [item.get("image_url") for item in items if item.get("image_source") != "user"]
|
|
168
|
+
report.append({"idea_id": key, "channels": [item.get("channel") for item in items], "picture": automatic[0] if automatic and not problems else None, "shared": not problems})
|
|
169
|
+
return {"valid": not errors, "errors": errors, "ideas": report}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
REUSE_DAYS = 30
|
|
173
|
+
SHARED_STATUSES = {"approved", "scheduled", "publishing", "published"}
|
|
174
|
+
OPEN_STATUSES = {"draft", "queued", "held", "needs_review", "pending"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def article_key(item: dict) -> str:
|
|
178
|
+
"""The article a post shares: its source id, else its address without tracking parameters."""
|
|
179
|
+
if item.get("source_content_id"):
|
|
180
|
+
return "id:" + str(item["source_content_id"])
|
|
181
|
+
url = str(item.get("canonical_url") or item.get("url") or "")
|
|
182
|
+
parts = urlsplit(url)
|
|
183
|
+
query = urlencode([(key, value) for key, value in parse_qsl(parts.query) if not key.startswith("utm_")])
|
|
184
|
+
return "url:" + urlunsplit((parts.scheme, parts.netloc.lower().removeprefix("www."), parts.path.rstrip("/"), query, ""))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def shared_on(history: list[dict], now: datetime, days: int = REUSE_DAYS) -> dict[str, dict[str, str]]:
|
|
188
|
+
"""Article key -> {channel: last shared instant} within the rolling window."""
|
|
189
|
+
used: dict[str, dict[str, str]] = {}
|
|
190
|
+
for post in history:
|
|
191
|
+
if post.get("status", "published") not in SHARED_STATUSES:
|
|
192
|
+
continue
|
|
193
|
+
stamp = post.get("posted_at") or post.get("published_at") or post.get("scheduled_at")
|
|
194
|
+
if not stamp or not post.get("channel"):
|
|
195
|
+
continue
|
|
196
|
+
when = parse_instant(stamp, "history posted_at")
|
|
197
|
+
if (now - when).total_seconds() > days * 86400 or when > now + timedelta(days=days):
|
|
198
|
+
continue
|
|
199
|
+
channels = used.setdefault(article_key(post), {})
|
|
200
|
+
channel = str(post["channel"])
|
|
201
|
+
channels[channel] = max(channels.get(channel, ""), stamp)
|
|
202
|
+
return used
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def sources_used_on(document: dict, now: datetime) -> dict:
|
|
206
|
+
"""Each article with the channels it was shared to in the last 30 days (rolling)."""
|
|
207
|
+
used = shared_on(document.get("history") or [], now, int(document.get("reuse_days") or REUSE_DAYS))
|
|
208
|
+
articles = []
|
|
209
|
+
for article in document.get("articles") or []:
|
|
210
|
+
channels = used.get(article_key(article), {})
|
|
211
|
+
articles.append({**article, "used_on": sorted(channels), "last_shared": channels})
|
|
212
|
+
return {"valid": True, "reuse_days": int(document.get("reuse_days") or REUSE_DAYS), "articles": articles}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def queue_check(document: dict, now: datetime) -> dict:
|
|
216
|
+
"""What an automatic run and "Approve all" may do with the open posts.
|
|
217
|
+
|
|
218
|
+
* reuse is per channel: an article shared on a channel in the last 30 days
|
|
219
|
+
(rolling) is not shared there again — other channels are free;
|
|
220
|
+
* every new post carries a picture;
|
|
221
|
+
* in auto mode a hold raised only by optional checks does not block a post;
|
|
222
|
+
a failing required check always does;
|
|
223
|
+
* "Approve all" covers every open post, held ones included.
|
|
224
|
+
"""
|
|
225
|
+
mode = str(document.get("approval_mode") or "manual")
|
|
226
|
+
if mode not in {"auto", "manual"}:
|
|
227
|
+
raise ValueError("approval_mode must be auto or manual")
|
|
228
|
+
days = int(document.get("reuse_days") or REUSE_DAYS)
|
|
229
|
+
used = shared_on(document.get("history") or [], now, days)
|
|
230
|
+
planned: dict[tuple[str, str], str] = {}
|
|
231
|
+
posts, approve_all, blocked = [], [], []
|
|
232
|
+
for post in document.get("posts") or []:
|
|
233
|
+
if post.get("status", "draft") not in OPEN_STATUSES:
|
|
234
|
+
continue
|
|
235
|
+
pid, channel, key = str(post.get("id")), str(post.get("channel") or ""), article_key(post)
|
|
236
|
+
reasons, notes = [], []
|
|
237
|
+
last = used.get(key, {}).get(channel)
|
|
238
|
+
if last:
|
|
239
|
+
reasons.append(f"this article was shared on {channel} at {last}, within {days} days")
|
|
240
|
+
elif (channel, key) in planned:
|
|
241
|
+
reasons.append(f"post {planned[(channel, key)]} already shares this article on {channel}")
|
|
242
|
+
planned.setdefault((channel, key), pid)
|
|
243
|
+
if not post.get("image_url"):
|
|
244
|
+
reasons.append("needs a picture: every new post carries one (use the picture of its idea)")
|
|
245
|
+
for hold in post.get("holds") or []:
|
|
246
|
+
name = str(hold.get("check") or hold.get("reason") or "check")
|
|
247
|
+
if hold.get("required"):
|
|
248
|
+
reasons.append(f"required check failed: {name}")
|
|
249
|
+
elif mode == "auto":
|
|
250
|
+
notes.append(f"optional check {name} does not hold the post in auto mode")
|
|
251
|
+
else:
|
|
252
|
+
notes.append(f"held for review by optional check {name}; Approve all includes it")
|
|
253
|
+
held = bool(post.get("holds")) or post.get("status") in {"held", "needs_review"}
|
|
254
|
+
entry = {"id": pid, "channel": channel, "held": held, "blocked": bool(reasons), "reasons": reasons, "notes": notes}
|
|
255
|
+
if mode == "auto":
|
|
256
|
+
entry["auto_publish"] = not reasons
|
|
257
|
+
posts.append(entry)
|
|
258
|
+
(blocked if reasons else approve_all).append(pid)
|
|
259
|
+
return {"valid": not blocked, "approval_mode": mode, "reuse_days": days, "approve_all": approve_all, "blocked": blocked, "posts": posts}
|
|
260
|
+
|
|
261
|
+
|
|
76
262
|
def validate(value: dict) -> list[str]:
|
|
77
263
|
errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
|
|
78
264
|
if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
|
|
@@ -84,7 +270,10 @@ def validate(value: dict) -> list[str]:
|
|
|
84
270
|
errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
|
|
85
271
|
elif not domain and value.get("status") in LIVE_STATUSES:
|
|
86
272
|
errors.append("workspace_domain is required before approval: the source must be published on the workspace's own domain")
|
|
87
|
-
|
|
273
|
+
channels = value.get("channels", [])
|
|
274
|
+
if value.get("image_url"):
|
|
275
|
+
errors.extend(shared_picture_errors([{**channel, "image_url": channel.get("image_url", value["image_url"])} for channel in channels], "draft"))
|
|
276
|
+
for channel in channels:
|
|
88
277
|
name = channel.get("name")
|
|
89
278
|
if name not in LIMITS:
|
|
90
279
|
errors.append(f"unsupported channel: {name}")
|
|
@@ -114,6 +303,19 @@ def main() -> int:
|
|
|
114
303
|
create.add_argument("--channels", default="linkedin,facebook")
|
|
115
304
|
create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
|
|
116
305
|
create.add_argument("--output")
|
|
306
|
+
create.add_argument("--check-link", action="store_true", help="fetch canonical_url first and refuse a definite 404/410")
|
|
307
|
+
pictures = sub.add_parser("pictures", help="check that drafts written from one idea share one picture on every channel")
|
|
308
|
+
pictures.add_argument("input", help='JSON: {"drafts": [{"id", "idea_id", "channel", "image_url", "image_source"?}]}')
|
|
309
|
+
links = sub.add_parser("link-check", help="drop articles whose link is gone (404/410) before planning posts; keep blocked or failing pages")
|
|
310
|
+
links.add_argument("input", help="JSON: a list of URLs or article objects with canonical_url, or {\"articles\": [...]}")
|
|
311
|
+
links.add_argument("--cache", default=".maggie/social-link-cache.json", help="verdict cache (default: .maggie/social-link-cache.json); 'none' disables it")
|
|
312
|
+
links.add_argument("--ttl-hours", type=float, default=6.0)
|
|
313
|
+
used_on = sub.add_parser("used-on", help="list, per article, the channels it was shared to in the last 30 days (rolling)")
|
|
314
|
+
used_on.add_argument("input", help='JSON: {"articles": [...], "history": [{"channel", "source_content_id"|"canonical_url", "posted_at", "status"}]}')
|
|
315
|
+
used_on.add_argument("--now")
|
|
316
|
+
qcheck = sub.add_parser("queue-check", help="decide what auto mode and Approve all may do: per-channel 30-day reuse, a picture on every post, optional holds")
|
|
317
|
+
qcheck.add_argument("input", help='JSON: {"approval_mode": "auto"|"manual", "posts": [...], "history": [...]}')
|
|
318
|
+
qcheck.add_argument("--now")
|
|
117
319
|
check = sub.add_parser("validate", help="validate a draft channel contract")
|
|
118
320
|
check.add_argument("input")
|
|
119
321
|
adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
|
|
@@ -134,6 +336,15 @@ def main() -> int:
|
|
|
134
336
|
schema.add_argument("input")
|
|
135
337
|
args = parser.parse_args()
|
|
136
338
|
try:
|
|
339
|
+
if args.command == "link-check":
|
|
340
|
+
raw = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
341
|
+
articles = raw.get("articles", []) if isinstance(raw, dict) else raw
|
|
342
|
+
urls = [item if isinstance(item, str) else item.get("canonical_url") or item.get("url") for item in articles]
|
|
343
|
+
result = check_links([url for url in urls if url], None if args.cache == "none" else Path(args.cache), args.ttl_hours, datetime.now(timezone.utc))
|
|
344
|
+
by_url = {item["url"]: item for item in result["results"]}
|
|
345
|
+
result["articles"] = [item for item in articles if by_url.get(item if isinstance(item, str) else (item.get("canonical_url") or item.get("url")), {}).get("verdict") != "gone"]
|
|
346
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
347
|
+
return 0
|
|
137
348
|
value = read(Path(args.input))
|
|
138
349
|
now = datetime.now(timezone.utc)
|
|
139
350
|
if args.command == "schema-check":
|
|
@@ -143,6 +354,12 @@ def main() -> int:
|
|
|
143
354
|
result = validate_adapters(value)
|
|
144
355
|
elif args.command == "connections":
|
|
145
356
|
result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
|
|
357
|
+
elif args.command == "used-on":
|
|
358
|
+
result = sources_used_on(value, parse_instant(args.now, "--now") if args.now else now)
|
|
359
|
+
elif args.command == "queue-check":
|
|
360
|
+
result = queue_check(value, parse_instant(args.now, "--now") if args.now else now)
|
|
361
|
+
elif args.command == "pictures":
|
|
362
|
+
result = picture_groups(value if isinstance(value, dict) else {"drafts": value})
|
|
146
363
|
elif args.command == "reconcile":
|
|
147
364
|
result = reconcile(value)
|
|
148
365
|
elif args.command == "campaigns":
|
|
@@ -156,6 +373,10 @@ def main() -> int:
|
|
|
156
373
|
return 0
|
|
157
374
|
else:
|
|
158
375
|
if args.command == "plan":
|
|
376
|
+
if args.check_link and value.get("canonical_url"):
|
|
377
|
+
status = fetch_status(value["canonical_url"])
|
|
378
|
+
if link_verdict(status) == "gone":
|
|
379
|
+
raise ValueError(f"canonical_url answers HTTP {status}: the article is gone; pick a live article (blocked or failing pages are not treated as gone)")
|
|
159
380
|
value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
|
|
160
381
|
if args.output:
|
|
161
382
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check that every version source of a package agrees, and that generated data is current.
|
|
3
|
+
|
|
4
|
+
A packager that writes its own version constant over a hand-bumped manifest
|
|
5
|
+
(or the reverse) ships one version in one place and another elsewhere; a site
|
|
6
|
+
built from generated data that was not regenerated then fails to deploy. This
|
|
7
|
+
check never writes a version: it reads each source and refuses on a mismatch.
|
|
8
|
+
|
|
9
|
+
Sources:
|
|
10
|
+
FILE a plain VERSION file (first line)
|
|
11
|
+
FILE:dotted.key a JSON value, e.g. package.json:version or manifest.json:meta.version
|
|
12
|
+
FILE~NAME a code constant NAME = "x.y.z" / NAME: "x.y.z" / export const NAME = 'x.y.z'
|
|
13
|
+
|
|
14
|
+
Generated data:
|
|
15
|
+
--generated OUT --command "node scripts/gen.mjs {out}"
|
|
16
|
+
runs the generator into a temporary file ({out}) and fails when OUT differs.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shlex
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_source(root: Path, spec: str) -> str:
|
|
31
|
+
if "~" in spec:
|
|
32
|
+
name, constant = spec.split("~", 1)
|
|
33
|
+
text = (root / name).read_text(encoding="utf-8")
|
|
34
|
+
match = re.search(rf"\b{re.escape(constant)}\b\s*[:=]\s*['\"]([^'\"]+)['\"]", text)
|
|
35
|
+
if not match:
|
|
36
|
+
raise ValueError(f"{spec}: constant {constant} not found")
|
|
37
|
+
return match.group(1)
|
|
38
|
+
if ":" in spec and not re.match(r"^[A-Za-z]:[\\/]", spec):
|
|
39
|
+
name, dotted = spec.split(":", 1)
|
|
40
|
+
value: object = json.loads((root / name).read_text(encoding="utf-8"))
|
|
41
|
+
for key in dotted.split("."):
|
|
42
|
+
if not isinstance(value, dict) or key not in value:
|
|
43
|
+
raise ValueError(f"{spec}: key {dotted} not found")
|
|
44
|
+
value = value[key]
|
|
45
|
+
return str(value)
|
|
46
|
+
return (root / spec).read_text(encoding="utf-8").splitlines()[0].strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_generated(root: Path, output: str, command: str) -> dict:
|
|
50
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
51
|
+
fresh = Path(directory) / Path(output).name
|
|
52
|
+
argv = [part.replace("{out}", str(fresh)) for part in shlex.split(command)]
|
|
53
|
+
completed = subprocess.run(argv, cwd=root, capture_output=True, text=True, check=False)
|
|
54
|
+
if completed.returncode:
|
|
55
|
+
return {"file": output, "current": False, "error": f"generator exited {completed.returncode}: {(completed.stderr or completed.stdout).strip()[-300:]}"}
|
|
56
|
+
if not fresh.is_file():
|
|
57
|
+
return {"file": output, "current": False, "error": "generator did not write {out}"}
|
|
58
|
+
current = (root / output).is_file() and (root / output).read_bytes() == fresh.read_bytes()
|
|
59
|
+
return {"file": output, "current": current, **({} if current else {"error": "out of date: regenerate and commit it before deploying"})}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter, epilog="\n".join(__doc__.splitlines()[2:]))
|
|
64
|
+
parser.add_argument("command", choices=["check"])
|
|
65
|
+
parser.add_argument("--project", default=".")
|
|
66
|
+
parser.add_argument("--source", action="append", default=[], required=True, help="a version source (repeat; the first is the one to follow)")
|
|
67
|
+
parser.add_argument("--generated", action="append", default=[], help="generated file that must be current")
|
|
68
|
+
parser.add_argument("--generator", action="append", default=[], help="command that writes the matching --generated file to {out}")
|
|
69
|
+
args = parser.parse_args()
|
|
70
|
+
root = Path(args.project).resolve()
|
|
71
|
+
errors: list[str] = []
|
|
72
|
+
versions: dict[str, str | None] = {}
|
|
73
|
+
for spec in args.source:
|
|
74
|
+
try:
|
|
75
|
+
versions[spec] = read_source(root, spec)
|
|
76
|
+
except (OSError, ValueError, json.JSONDecodeError, IndexError) as error:
|
|
77
|
+
versions[spec] = None
|
|
78
|
+
errors.append(str(error))
|
|
79
|
+
found = {value for value in versions.values() if value}
|
|
80
|
+
if len(found) > 1:
|
|
81
|
+
first = versions[args.source[0]]
|
|
82
|
+
errors.extend(f"{spec} is {value}; {args.source[0]} is {first}" for spec, value in versions.items() if value and value != first)
|
|
83
|
+
if len(args.generated) != len(args.generator):
|
|
84
|
+
errors.append("give one --generator per --generated file")
|
|
85
|
+
generated = [check_generated(root, output, command) for output, command in zip(args.generated, args.generator)]
|
|
86
|
+
errors.extend(f"{item['file']}: {item['error']}" for item in generated if not item["current"])
|
|
87
|
+
result = {"valid": not errors, "version": versions.get(args.source[0]) if not errors else None, "sources": versions, "generated": generated, "errors": errors}
|
|
88
|
+
print(json.dumps(result, indent=2))
|
|
89
|
+
return 0 if result["valid"] else 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|
|
@@ -74,6 +74,55 @@ The import is draft-only and never publishes. It rejects missing IDs, invalid
|
|
|
74
74
|
slugs, and empty content; changed source checksums stop for manual conflict
|
|
75
75
|
review. Approval and publication are separate MaggieDash transitions.
|
|
76
76
|
|
|
77
|
+
## Where a delivered post went live
|
|
78
|
+
|
|
79
|
+
A pulled post is useful to other channels (social posts that link it,
|
|
80
|
+
internal links) only once its live URL is known; without it a planner falls
|
|
81
|
+
back to an older stored URL, which after a platform move answers 404. So:
|
|
82
|
+
|
|
83
|
+
1. When the site publishes a delivered post, it reports the URL it went live
|
|
84
|
+
at (`POST /content-tracking/report-state` with the content ID, the URL and
|
|
85
|
+
`published`) and records it locally:
|
|
86
|
+
`maggie blog live-urls record --content-id ID --url URL`. The site's report
|
|
87
|
+
is authoritative.
|
|
88
|
+
2. A scheduled sitemap match fills the gaps:
|
|
89
|
+
`maggie blog live-urls match --sitemap https://example.com/sitemap.xml --write --report-body .maggie/live-urls-report.json`,
|
|
90
|
+
then send that body with `maggie api report-state --body-file … --execute`.
|
|
91
|
+
|
|
92
|
+
Every post without a live URL is a candidate, including one already tracked
|
|
93
|
+
with an empty value; when the record has no slug, the last path segment of its
|
|
94
|
+
delivered address is used. Matching is **additive**: a post that already has a live URL keeps it, and a
|
|
95
|
+
post this run did not find is left as it was — never marked unmatched — so the
|
|
96
|
+
match can run daily without retiring earlier matches. When translated copies
|
|
97
|
+
share the slug (`/blog/x`, `/zh-hans/blog/x`), the URL **without a locale
|
|
98
|
+
segment** wins. The same two rules apply to the platform's own sitemap
|
|
99
|
+
matching for API Pull destinations.
|
|
100
|
+
|
|
101
|
+
## Rewrites on pull sites
|
|
102
|
+
|
|
103
|
+
A pull site receives rewrites the same way it receives posts: `GET /updates`
|
|
104
|
+
on the same schedule as `/posts` (one rewrite per call, charged like a post;
|
|
105
|
+
the answer is `{delivered, updates: [...]}`). Without this path a rewrite
|
|
106
|
+
requested for a pulled post never reaches the site. Each rewrite is applied to
|
|
107
|
+
the post it is for, never stored as a new post:
|
|
108
|
+
|
|
109
|
+
1. Find the post by `source_content_id` (the id `/posts` delivered it under),
|
|
110
|
+
else by its slug or address, previous slugs included.
|
|
111
|
+
2. Replace only the body. Title, slug, status, topics and the live URL stay as
|
|
112
|
+
the site has them.
|
|
113
|
+
3. Keep the replaced body (and its checksum) so the rewrite can be undone.
|
|
114
|
+
4. A rewrite with `publication_status: "draft"` is held for review, and one
|
|
115
|
+
with no matching post here is kept as `unmatched`; neither is applied.
|
|
116
|
+
5. Translations of a rewritten post are stale: queue them again.
|
|
117
|
+
6. Report the outcome with `POST /content-tracking/report-state`.
|
|
118
|
+
|
|
119
|
+
`maggie api lifecycle --execute --allow-quota --pull-updates` does steps 1, 2,
|
|
120
|
+
4 locally: the rewrite lands on the original record in
|
|
121
|
+
`.maggie/ai-cmo-lifecycle.json` (`rewrite_pending`, a `rewrites` history with
|
|
122
|
+
the previous checksum), held ones are listed without changing the body, and
|
|
123
|
+
unmatched ones go to `rewrites_unmatched`. A later `/posts` re-delivery keeps
|
|
124
|
+
the record's live URL and rewrite history.
|
|
125
|
+
|
|
77
126
|
## Project context synchronization
|
|
78
127
|
|
|
79
128
|
`GET /project-context` returns sanitized, read-only context scoped by the API
|