@acedatacloud/skills 2026.703.0 → 2026.703.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.703.0",
3
+ "version": "2026.703.1",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -25,7 +25,9 @@ The connector injects the cookie jar as an env var:
25
25
 
26
26
  - `MEDIUM_COOKIES` — a JSON array of cookies (`sid`, `uid`, `xsrf`). **Secret —
27
27
  never echo or print it.** The CLI echoes the `xsrf` cookie as the
28
- `x-xsrf-token` header on writes for you.
28
+ `x-xsrf-token` header on writes for you. If the captured jar has no `xsrf`
29
+ cookie (common), the CLI mints one automatically before writing, so publishes
30
+ no longer fail with "Missing xsrf token".
29
31
 
30
32
  ## CLI
31
33
 
@@ -51,7 +53,11 @@ On an auth error the cookie is expired — have the user reconnect at
51
53
  ## Publishing — GATED (dry-run unless trailing `--confirm`)
52
54
 
53
55
  `publish` writes to the user's real account. Content is **Markdown** (converted
54
- to Medium paragraph blocks: `#`→H1, `##`→H2, `>`→quote, ```` ``` ````→code).
56
+ to Medium paragraph blocks: `#`/`##`→heading, `###`→sub-heading, `>`→quote,
57
+ ```` ``` ````→code, `-`/`*`→bullet list, `1.`→numbered list, tables→aligned
58
+ code block). Inline **bold**, *italic*, `code`, and `[links](url)` render as
59
+ real Medium formatting (clickable links included). Bare `https://…` URLs in text
60
+ are auto-linked too (URLs inside code spans are left untouched).
55
61
  Without a trailing `--confirm` it dry-runs. `--confirm` is honored **only as the
56
62
  last argument**. Always show the dry-run, get an explicit "yes", then re-run.
57
63
 
@@ -76,9 +82,12 @@ to a link paragraph (never blocks the post).
76
82
  ## Gotchas
77
83
 
78
84
  - **This is the user's real Medium account.** Confirm before any publish.
79
- - Markdown→Medium conversion is paragraph-level (headings, quotes, code, body);
80
- complex inline formatting / images aren't converted the user can polish in
81
- the Medium editor before going public.
85
+ - Markdown→Medium conversion covers headings, quotes, fenced code, bullet/
86
+ numbered lists, images, and inline markups (bold/italic/code/links). Medium's
87
+ editor has no table element: a narrow markdown table renders as an aligned
88
+ monospace code block, while a wide one (long cells / URLs, which would wrap into
89
+ an unreadable grid) is rendered as per-row records — the first column bolded as
90
+ a lead-in, the remaining columns as `header: value` bullets.
82
91
  - Medium sits behind Cloudflare; an occasional 403/429 is transient — the CLI
83
92
  auto-retries once after a short pause. A *persistent* 403 means the cookie is
84
93
  genuinely expired (reconnect).
@@ -174,6 +174,32 @@ def api(method, url, jar, *, body=None, _retried=False):
174
174
  return d
175
175
 
176
176
 
177
+ def prime_xsrf(jar) -> None:
178
+ """Ensure the jar carries an ``xsrf`` cookie. Captured jars frequently lack
179
+ it, but Medium hands one out on any GET of the site; without it the
180
+ ``x-xsrf-token`` header is absent and every write fails ('Missing xsrf token')."""
181
+ if cookie_value(jar, "xsrf"):
182
+ return
183
+ url = BASE + "/"
184
+ hdrs = {"User-Agent": UA, "Accept": "text/html",
185
+ "X-Requested-With": "XMLHttpRequest"}
186
+ req = urllib.request.Request(url, headers=hdrs, method="GET")
187
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
188
+ try:
189
+ with urllib.request.urlopen(req, timeout=30) as resp:
190
+ set_cookies = resp.headers.get_all("Set-Cookie") or []
191
+ except urllib.error.HTTPError as e:
192
+ set_cookies = e.headers.get_all("Set-Cookie") or []
193
+ except urllib.error.URLError:
194
+ return
195
+ for sc in set_cookies:
196
+ m = re.match(r"\s*xsrf=([^;]+)", sc)
197
+ if m:
198
+ jar.append({"name": "xsrf", "value": m.group(1).strip('"'),
199
+ "domain": ".medium.com", "path": "/"})
200
+ return
201
+
202
+
177
203
  # ── commands ────────────────────────────────────────────────────────
178
204
 
179
205
  def md_me(jar):
@@ -336,15 +362,163 @@ def medium_upload_image(jar, post_id, img_bytes, content_type):
336
362
  return val["fileId"], val.get("imgWidth"), val.get("imgHeight")
337
363
 
338
364
 
365
+ # Inline markdown → Medium markups. Groups: 1/2 link, 3 bold, 4 code, 5 italic,
366
+ # 6 bare URL (autolinked).
367
+ _INLINE = re.compile(
368
+ r"\[([^\]]+)\]\((https?://[^)\s]+)\)"
369
+ r"|\*\*([^*]+)\*\*"
370
+ r"|`([^`]+)`"
371
+ r"|(?<![\w*])\*([^*\n]+)\*(?![\w*])"
372
+ r"|(https?://[^\s<>()\[\]]+)"
373
+ )
374
+ _TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$")
375
+ _LIST_ITEM = re.compile(r"^\s*([-*+]|\d+\.)\s+(.*)$")
376
+ # Medium's code column wraps past ~66 monospace chars; wider tables are rendered
377
+ # as per-row records instead of an unreadable wrapped grid.
378
+ _TABLE_MAX_W = 66
379
+
380
+
381
+ def _u16(s: str) -> int:
382
+ """Length in UTF-16 code units — Medium indexes markup offsets like JS."""
383
+ return len(s.encode("utf-16-le")) // 2
384
+
385
+
386
+ def _inline(text: str):
387
+ """Parse inline markdown → (plain_text, markups). Emits Medium markups for
388
+ links (type 3), bold (1), italic (2) and inline code (10) so they render as
389
+ real formatting instead of literal ``**`` / ``[..](..)`` characters. Bold,
390
+ italic and link labels are parsed recursively so nested markup (e.g. a link
391
+ inside bold) is preserved as overlapping markups rather than dropped."""
392
+ plain: list[str] = []
393
+ markups: list[dict] = []
394
+ pos = 0
395
+ off = 0 # running UTF-16 offset into the assembled plain text
396
+
397
+ def _nest(inner: str, start: int):
398
+ sub_plain, sub_mk = _inline(inner)
399
+ plain.append(sub_plain)
400
+ for x in sub_mk:
401
+ markups.append({**x, "start": x["start"] + start, "end": x["end"] + start})
402
+ return _u16(sub_plain)
403
+
404
+ for m in _INLINE.finditer(text):
405
+ if m.start() > pos:
406
+ seg = text[pos:m.start()]
407
+ plain.append(seg)
408
+ off += _u16(seg)
409
+ start = off
410
+ if m.group(1) is not None: # link
411
+ off += _nest(m.group(1), start)
412
+ markups.append({"type": 3, "start": start, "end": off,
413
+ "href": m.group(2), "title": "", "rel": "", "anchorType": 0})
414
+ elif m.group(3) is not None: # bold
415
+ off += _nest(m.group(3), start)
416
+ markups.append({"type": 1, "start": start, "end": off})
417
+ elif m.group(4) is not None: # inline code — literal, no recursion
418
+ inner = m.group(4)
419
+ plain.append(inner); off += _u16(inner)
420
+ markups.append({"type": 10, "start": start, "end": off})
421
+ elif m.group(5) is not None: # italic
422
+ off += _nest(m.group(5), start)
423
+ markups.append({"type": 2, "start": start, "end": off})
424
+ elif m.group(6) is not None: # bare URL → autolink
425
+ url = m.group(6)
426
+ tail = ""
427
+ while url and url[-1] in ".,;:!?。,;:!?":
428
+ tail = url[-1] + tail
429
+ url = url[:-1]
430
+ plain.append(url); off += _u16(url)
431
+ markups.append({"type": 3, "start": start, "end": off,
432
+ "href": url, "title": "", "rel": "", "anchorType": 0})
433
+ if tail:
434
+ plain.append(tail); off += _u16(tail)
435
+ pos = m.end()
436
+ if pos < len(text):
437
+ plain.append(text[pos:])
438
+ return "".join(plain), markups
439
+
440
+
441
+ def _is_table(lines):
442
+ return (len(lines) >= 2 and "|" in lines[0]
443
+ and "-" in lines[1] and _TABLE_SEP.match(lines[1]) is not None)
444
+
445
+
446
+ def _split_row(line):
447
+ line = line.strip()
448
+ if line.startswith("|"):
449
+ line = line[1:]
450
+ if line.endswith("|"):
451
+ line = line[:-1]
452
+ return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line)]
453
+
454
+
455
+ def _render_table(lines):
456
+ """Markdown table → aligned monospace text (rendered as a PRE code block, the
457
+ closest faithful representation since Medium's editor has no table element)."""
458
+ rows = [_split_row(ln) for i, ln in enumerate(lines) if i != 1]
459
+ ncol = max(len(r) for r in rows)
460
+ rows = [r + [""] * (ncol - len(r)) for r in rows]
461
+ widths = [max(len(r[c]) for r in rows) for c in range(ncol)]
462
+ def fmt(r):
463
+ return " | ".join(r[c].ljust(widths[c]) for c in range(ncol)).rstrip()
464
+ sep = "-+-".join("-" * widths[c] for c in range(ncol))
465
+ body = [fmt(rows[0]), sep] + [fmt(r) for r in rows[1:]]
466
+ return "\n".join(body)
467
+
468
+
469
+ def _table_paras(lines):
470
+ """Markdown table → Medium paragraphs. A table that fits Medium's ~66-char
471
+ code column keeps the aligned monospace grid; a wider one (long notes / full
472
+ URLs would wrap into an unreadable mess) becomes a per-row record — the first
473
+ cell as a bold lead-in, the remaining columns as ``header: value`` bullets.
474
+ Both paths stay inside the stdlib-only, no-image contract."""
475
+ grid = _render_table(lines)
476
+ if max((len(ln) for ln in grid.split("\n")), default=0) <= _TABLE_MAX_W:
477
+ return [{"type": 8, "text": grid}]
478
+ rows = [_split_row(ln) for i, ln in enumerate(lines) if i != 1]
479
+ header = rows[0] if rows else []
480
+ out = []
481
+ for r in rows[1:]:
482
+ cells = [c.strip() for c in r]
483
+ if not any(cells):
484
+ continue
485
+ if cells[0]: # lead cell → whole-cell bold
486
+ txt, mk = _inline(cells[0])
487
+ mk.append({"type": 1, "start": 0, "end": _u16(txt)})
488
+ out.append({"type": 1, "text": txt, "markups": mk})
489
+ for i in range(1, len(cells)):
490
+ if not cells[i]:
491
+ continue
492
+ vtxt, vmk = _inline(cells[i])
493
+ label = header[i].strip() if i < len(header) else ""
494
+ if label: # "label: value", label bolded
495
+ prefix = f"{label}: "
496
+ shift = _u16(prefix)
497
+ markups = [{"type": 1, "start": 0, "end": _u16(f"{label}:")}]
498
+ markups += [{**x, "start": x["start"] + shift, "end": x["end"] + shift}
499
+ for x in vmk]
500
+ out.append({"type": 9, "text": prefix + vtxt, "markups": markups})
501
+ else:
502
+ out.append({"type": 9, "text": vtxt, "markups": vmk})
503
+ return out or [{"type": 8, "text": grid}]
504
+
505
+
506
+ def _is_list(lines):
507
+ ls = [ln for ln in lines if ln.strip()]
508
+ return bool(ls) and all(_LIST_ITEM.match(ln) for ln in ls)
509
+
510
+
339
511
  def _build_deltas(jar, post_id, title, content, rehost=True):
340
- """Markdown → Medium paragraph deltas (image-aware). A standalone
341
- ``![alt](url)`` block is uploaded to Medium and emitted as an image
342
- paragraph (type 4); on upload failure it degrades to a link paragraph."""
512
+ """Markdown → Medium paragraph deltas. Handles standalone ``![alt](url)``
513
+ images (uploaded type-4 image paragraph), headings, quotes, fenced code,
514
+ tables (narrow aligned code block; wide per-row records), bullet/ordered
515
+ lists, and inline markups (links/bold/italic/code) on ordinary text."""
343
516
  paras = [{"type": 3, "text": title}]
344
517
  for block in re.split(r"\n\s*\n", content.strip()):
345
518
  b = block.strip("\n")
346
519
  if not b.strip():
347
520
  continue
521
+ lines = b.split("\n")
348
522
  first = b.lstrip()
349
523
  m = _MD_IMG.fullmatch(first)
350
524
  if m:
@@ -367,19 +541,34 @@ def _build_deltas(jar, post_id, title, content, rehost=True):
367
541
  continue
368
542
  if first.startswith("```"):
369
543
  paras.append({"type": 8, "text": re.sub(r"^```[^\n]*\n?|\n?```$", "", b)})
544
+ elif _is_table(lines):
545
+ paras.extend(_table_paras(lines))
546
+ elif _is_list(lines):
547
+ for ln in lines:
548
+ lm = _LIST_ITEM.match(ln)
549
+ if not lm:
550
+ continue
551
+ ptype = 10 if lm.group(1).endswith(".") else 9
552
+ txt, mk = _inline(lm.group(2).strip())
553
+ paras.append({"type": ptype, "text": txt, "markups": mk})
370
554
  elif first.startswith("# "):
371
- paras.append({"type": 12, "text": first[2:].strip()})
555
+ txt, mk = _inline(first[2:].strip())
556
+ paras.append({"type": 2, "text": txt, "markups": mk})
372
557
  elif first.startswith("## "):
373
- paras.append({"type": 2, "text": first[3:].strip()})
558
+ txt, mk = _inline(first[3:].strip())
559
+ paras.append({"type": 2, "text": txt, "markups": mk})
374
560
  elif first.startswith("### "):
375
- paras.append({"type": 3, "text": first[4:].strip()})
561
+ txt, mk = _inline(first[4:].strip())
562
+ paras.append({"type": 3, "text": txt, "markups": mk})
376
563
  elif first.startswith("> "):
377
- paras.append({"type": 6, "text": first[2:].strip()})
564
+ txt, mk = _inline(first[2:].strip())
565
+ paras.append({"type": 6, "text": txt, "markups": mk})
378
566
  else:
379
- paras.append({"type": 1, "text": b.replace("\n", " ").strip()})
567
+ txt, mk = _inline(b.replace("\n", " ").strip())
568
+ paras.append({"type": 1, "text": txt, "markups": mk})
380
569
  out_deltas = []
381
570
  for i, p in enumerate(paras):
382
- para = {"type": p["type"], "text": p["text"], "markups": []}
571
+ para = {"type": p["type"], "text": p["text"], "markups": p.get("markups") or []}
383
572
  if "layout" in p:
384
573
  para["layout"] = p["layout"]
385
574
  if "metadata" in p:
@@ -413,6 +602,12 @@ def cmd_publish(jar, args):
413
602
  })
414
603
  return
415
604
 
605
+ # Captured jars often lack an xsrf cookie; mint one so writes don't 400.
606
+ prime_xsrf(jar)
607
+ if not cookie_value(jar, "xsrf"):
608
+ die("could not obtain an xsrf token from Medium (cookie may be expired) — "
609
+ "reconnect at https://auth.acedata.cloud/user/connections.")
610
+
416
611
  # 1. create empty draft
417
612
  d = api("POST", f"{BASE}/new-story", jar, body={})
418
613
  payload = d.get("payload") or {}