@acedatacloud/skills 2026.703.0 → 2026.703.2
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.md +1 -0
- package/package.json +1 -1
- package/skills/medium/SKILL.md +14 -5
- package/skills/medium/scripts/medium.py +204 -9
- package/skills/tencent-docs/SKILL.md +224 -0
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ These skills drive third-party connectors users wire up at [auth.acedata.cloud/u
|
|
|
71
71
|
| [wechat-official-account](skills/wechat-official-account/) | Manage WeChat MP — drafts, publishing, materials, user tags | `wechat` (BYOC) |
|
|
72
72
|
| [personal-wechat](skills/personal-wechat/) | Operate a personal WeChat account through a self-hosted Wisdom service | `personalwechat` (BYOC) |
|
|
73
73
|
| [wecom](skills/wecom/) | WeCom (企业微信) self-built app — contacts, app messages, WeDoc, schedules, meetings | `wecom` (BYOC) |
|
|
74
|
+
| [tencent-docs](skills/tencent-docs/) | Create / read / list / search / manage Tencent Docs — docs, sheets, slides, mind maps, flowcharts | `tencentdocs` (BYOC) |
|
|
74
75
|
|
|
75
76
|
## Prerequisites
|
|
76
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.703.
|
|
3
|
+
"version": "2026.703.2",
|
|
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",
|
package/skills/medium/SKILL.md
CHANGED
|
@@ -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:
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
512
|
+
"""Markdown → Medium paragraph deltas. Handles standalone ````
|
|
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
|
-
|
|
555
|
+
txt, mk = _inline(first[2:].strip())
|
|
556
|
+
paras.append({"type": 2, "text": txt, "markups": mk})
|
|
372
557
|
elif first.startswith("## "):
|
|
373
|
-
|
|
558
|
+
txt, mk = _inline(first[3:].strip())
|
|
559
|
+
paras.append({"type": 2, "text": txt, "markups": mk})
|
|
374
560
|
elif first.startswith("### "):
|
|
375
|
-
|
|
561
|
+
txt, mk = _inline(first[4:].strip())
|
|
562
|
+
paras.append({"type": 3, "text": txt, "markups": mk})
|
|
376
563
|
elif first.startswith("> "):
|
|
377
|
-
|
|
564
|
+
txt, mk = _inline(first[2:].strip())
|
|
565
|
+
paras.append({"type": 6, "text": txt, "markups": mk})
|
|
378
566
|
else:
|
|
379
|
-
|
|
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 {}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tencent-docs
|
|
3
|
+
description: Create, read, list, search, and manage Tencent Docs (腾讯文档) — online documents, sheets, slides, mind maps, flowcharts, smart tables, and forms — via the Tencent Docs Open API. Use when the user mentions 腾讯文档 / Tencent Docs / docs.qq.com, a docs.qq.com link, or wants to create / read / organize a doc, sheet, slide, mind map, or flowchart in their Tencent Docs space.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger when the user wants to work with their Tencent Docs (腾讯文档) space —
|
|
6
|
+
create a new document / sheet / slide / mind map / flowchart / smart table /
|
|
7
|
+
form, read a doc or sheet's content, list a folder, search files by keyword,
|
|
8
|
+
or rename / move / copy / delete a file. Acts in the user's own Tencent Docs
|
|
9
|
+
account, so destructive or outward-facing writes are gated behind confirmation.
|
|
10
|
+
connections: [tencentdocs]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Tencent Docs (腾讯文档)
|
|
19
|
+
|
|
20
|
+
Drive the [Tencent Docs Open API](https://docs.qq.com/open/document/app/) with
|
|
21
|
+
`curl + jq`. Everything runs **in the user's own Tencent Docs account** — only
|
|
22
|
+
files they can access, only the scopes they granted at connect time.
|
|
23
|
+
|
|
24
|
+
## Auth — three headers (NOT a single Bearer)
|
|
25
|
+
|
|
26
|
+
The `tencentdocs` BYOC connector injects three env vars; every call sends **all
|
|
27
|
+
three** headers:
|
|
28
|
+
|
|
29
|
+
- `TENCENTDOCS_ACCESS_TOKEN` → `Access-Token` header (OAuth2 access token). **Secret.**
|
|
30
|
+
- `TENCENTDOCS_CLIENT_ID` → `Client-Id` header (the developer app's Client ID).
|
|
31
|
+
- `TENCENTDOCS_OPEN_ID` → `Open-Id` header (the user id returned with the token).
|
|
32
|
+
|
|
33
|
+
All endpoints live under `https://docs.qq.com/openapi`. Define a reusable header
|
|
34
|
+
set once per session:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
AUTH=(-H "Access-Token: $TENCENTDOCS_ACCESS_TOKEN" \
|
|
38
|
+
-H "Client-Id: $TENCENTDOCS_CLIENT_ID" \
|
|
39
|
+
-H "Open-Id: $TENCENTDOCS_OPEN_ID")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Never echo, print, or log `TENCENTDOCS_ACCESS_TOKEN` — it is full account access.
|
|
43
|
+
|
|
44
|
+
## Response shape & error handling
|
|
45
|
+
|
|
46
|
+
Every response is `{"ret": 0, "msg": "Succeed", "data": {…}}`. **`ret == 0`
|
|
47
|
+
means success**; any non-zero `ret` is an error — surface `msg` to the user.
|
|
48
|
+
Always check it:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
resp=$(curl -sS "${AUTH[@]}" … )
|
|
52
|
+
echo "$resp" | jq 'if .ret == 0 then .data else error("Tencent Docs \(.ret): \(.msg)") end'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Common error codes:
|
|
56
|
+
|
|
57
|
+
| code | meaning | what to do |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `400006` | access token invalid / expired | tell the user to reconnect 腾讯文档 at <https://auth.acedata.cloud/user/connections> — do **not** loop-retry |
|
|
60
|
+
| `400007` | VIP (超级会员) privilege required | the requested capability needs a Tencent Docs 超级会员; tell the user, link <https://docs.qq.com/vip> |
|
|
61
|
+
| `400008` | 积分 (credits) insufficient | AI-generation quota is exhausted; tell the user to top up |
|
|
62
|
+
| `11607` / `-32603` | bad request params | recheck `fileID` / `type` / body fields against the recipe below |
|
|
63
|
+
|
|
64
|
+
## Doc types (`type` when creating)
|
|
65
|
+
|
|
66
|
+
| type | Product | Notes |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `doc` | 在线文档 (Word-style) | classic rich-text document |
|
|
69
|
+
| `sheet` | 在线表格 (Excel) | data tables |
|
|
70
|
+
| `slide` | 幻灯片 (PPT) | presentations |
|
|
71
|
+
| `mind` | 思维导图 | mind map |
|
|
72
|
+
| `flowchart` | 流程图 | flowchart |
|
|
73
|
+
| `smartsheet` | 智能表格 | structured multi-view table |
|
|
74
|
+
| `form` | 收集表 | form / survey |
|
|
75
|
+
|
|
76
|
+
## Recipes
|
|
77
|
+
|
|
78
|
+
### Verify auth (always run first)
|
|
79
|
+
|
|
80
|
+
Cheap sanity check — read the app's OpenAPI usage counter:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
curl -sS "${AUTH[@]}" "https://docs.qq.com/openapi/drive/v2/util/resource-use" \
|
|
84
|
+
| jq 'if .ret == 0 then .data else "ERR \(.ret): \(.msg)" end'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If this returns `400006`, the connection is expired — have the user reconnect.
|
|
88
|
+
|
|
89
|
+
### List a folder (root = `/`)
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
curl -sS "${AUTH[@]}" \
|
|
93
|
+
"https://docs.qq.com/openapi/drive/v2/folders/%2F?listType=folder&sortType=browse&asc=0" \
|
|
94
|
+
| jq 'if .ret == 0 then [.data.list[]? | {ID, title, type, url}] else . end'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`%2F` is the URL-encoded root folder id. For a sub-folder use its folder id in
|
|
98
|
+
the path.
|
|
99
|
+
|
|
100
|
+
### Search files by keyword
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
curl -sS -G "${AUTH[@]}" \
|
|
104
|
+
"https://docs.qq.com/openapi/drive/v2/search" \
|
|
105
|
+
--data-urlencode "searchName=Q1 预算" \
|
|
106
|
+
| jq 'if .ret == 0 then [.data.list[]? | {ID, title, type, url}] else . end'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Read a file's metadata
|
|
110
|
+
|
|
111
|
+
The `fileID` is the last path segment of a `https://docs.qq.com/doc/<id>` (or
|
|
112
|
+
`/sheet/`, `/slide/`, …) URL.
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
curl -sS "${AUTH[@]}" \
|
|
116
|
+
"https://docs.qq.com/openapi/drive/v2/files/FILE_ID/metadata" \
|
|
117
|
+
| jq 'if .ret == 0 then .data else . end'
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Read an online document's content
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
curl -sS "${AUTH[@]}" \
|
|
124
|
+
"https://docs.qq.com/openapi/document/v3/files/FILE_ID/export?exportType=text" \
|
|
125
|
+
| jq 'if .ret == 0 then .data else . end'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Read a sheet's cell range
|
|
129
|
+
|
|
130
|
+
Get the sheet's `sheetId` list from the metadata first, then read a range
|
|
131
|
+
(`A1:D10`):
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
curl -sS "${AUTH[@]}" \
|
|
135
|
+
"https://docs.qq.com/openapi/spreadsheet/v2/files/FILE_ID/sheets/SHEET_ID/values?range=A1:D10" \
|
|
136
|
+
| jq 'if .ret == 0 then .data.values else . end'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Create a file
|
|
140
|
+
|
|
141
|
+
`POST /openapi/drive/v2/files` with form-urlencoded `title` + `type`. Returns the
|
|
142
|
+
new file's `ID` and `url`.
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
curl -sS -X POST "${AUTH[@]}" \
|
|
146
|
+
"https://docs.qq.com/openapi/drive/v2/files" \
|
|
147
|
+
--data-urlencode "title=会议纪要 2026-07-03" \
|
|
148
|
+
--data-urlencode "type=doc" \
|
|
149
|
+
| jq 'if .ret == 0 then {ID: .data.ID, url: .data.url} else . end'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Swap `type=sheet` / `slide` / `mind` / `flowchart` / `smartsheet` / `form` for
|
|
153
|
+
other doc types. Pass `--data-urlencode "folderID=<id>"` to create inside a
|
|
154
|
+
folder instead of the root.
|
|
155
|
+
|
|
156
|
+
### Upload an image (for embedding in docs)
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
curl -sS -X POST "${AUTH[@]}" \
|
|
160
|
+
"https://docs.qq.com/openapi/resources/v2/images" \
|
|
161
|
+
-F "file=@./cover.png" \
|
|
162
|
+
| jq 'if .ret == 0 then .data else . end'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Rename a file — GATED, confirm first
|
|
166
|
+
|
|
167
|
+
Show the user the current `title` + `url` and the new title, get an explicit
|
|
168
|
+
"yes", then run:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
curl -sS -X PATCH "${AUTH[@]}" \
|
|
172
|
+
"https://docs.qq.com/openapi/drive/v2/files/FILE_ID" \
|
|
173
|
+
--data-urlencode "title=新的标题" \
|
|
174
|
+
| jq 'if .ret == 0 then "renamed" else . end'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Move a file — GATED, confirm first
|
|
178
|
+
|
|
179
|
+
Show the user the file's current `title` + location and the destination folder,
|
|
180
|
+
get an explicit "yes", then run. `folderID=/` moves it back to the root.
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
curl -sS -X PATCH "${AUTH[@]}" \
|
|
184
|
+
"https://docs.qq.com/openapi/drive/v2/files/FILE_ID/move" \
|
|
185
|
+
--data-urlencode "folderID=DEST_FOLDER_ID" \
|
|
186
|
+
| jq 'if .ret == 0 then "moved" else . end'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Copy a file
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
curl -sS -X POST "${AUTH[@]}" \
|
|
193
|
+
"https://docs.qq.com/openapi/drive/v2/files/FILE_ID/copy" \
|
|
194
|
+
--data-urlencode "title=副本 - 项目计划" \
|
|
195
|
+
| jq 'if .ret == 0 then {ID: .data.ID, url: .data.url} else . end'
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Delete a file — GATED, confirm first
|
|
199
|
+
|
|
200
|
+
Deletion moves the file to the recycle bin but is still destructive. **Show the
|
|
201
|
+
user the exact `title` + `url`, get an explicit "yes", then run:**
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
curl -sS -X DELETE "${AUTH[@]}" \
|
|
205
|
+
"https://docs.qq.com/openapi/drive/v2/files/FILE_ID" \
|
|
206
|
+
| jq 'if .ret == 0 then "deleted" else . end'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Notes
|
|
210
|
+
|
|
211
|
+
- **Gate writes.** Rename / move / delete / copy and any share-permission change
|
|
212
|
+
act on the user's real files — confirm the exact target before running.
|
|
213
|
+
- **Extract ids from links.** When the user pastes a `docs.qq.com/doc/<id>` (or
|
|
214
|
+
`/sheet/`, `/slide/`, `/mind/`, `/flowchart/`, `/form/`, `/smartsheet/`) URL,
|
|
215
|
+
take the id from the path rather than asking for it.
|
|
216
|
+
- **Pagination.** List / search endpoints return a cursor / `next` marker in
|
|
217
|
+
`data`; pass it back to fetch the next page. Stop when the marker is empty.
|
|
218
|
+
- **Rate / quota.** Free apps get 20,000 API calls/month (超级会员 20,000/day,
|
|
219
|
+
Plus 40,000/day). AI-generation features additionally consume 积分 and may
|
|
220
|
+
require 超级会员 — a `400007` / `400008` means the account tier / credits, not a
|
|
221
|
+
bug in the request.
|
|
222
|
+
- **Full API index.** Beyond the recipes above, the Open API also covers
|
|
223
|
+
smart-table records, form collection, folder CRUD, permissions, and
|
|
224
|
+
import/export — see the [接口索引](https://docs.qq.com/open/document/app/openapi/v2/file/).
|