@acedatacloud/skills 2026.726.8 → 2026.726.10
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.726.
|
|
3
|
+
"version": "2026.726.10",
|
|
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",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cto51
|
|
3
|
+
description: Read the connected 51CTO 博客 (blog.51cto.com) account and create Markdown article drafts with the user's own login cookies (BYOC). Use when the user mentions 51CTO, wants to save a 51CTO draft, or asks who their connected 51CTO account is.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for the user's 51CTO 博客 account driven by their own login cookie:
|
|
6
|
+
show the connected account, or turn Markdown into a 51CTO article draft.
|
|
7
|
+
The write API creates a draft, so this skill stops there and hands the user
|
|
8
|
+
the editor URL. Writes are gated behind explicit confirmation.
|
|
9
|
+
connections: [cto51]
|
|
10
|
+
allowed_tools: [Bash]
|
|
11
|
+
license: Apache-2.0
|
|
12
|
+
metadata:
|
|
13
|
+
author: acedatacloud
|
|
14
|
+
version: "1.0"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# cto51 — read & draft on 51CTO 博客 via your own cookies
|
|
18
|
+
|
|
19
|
+
Drives the user's **real** 51CTO account through the same `blog.51cto.com`
|
|
20
|
+
endpoints the site uses, authenticated by the login cookie they captured with
|
|
21
|
+
the ACE extension. No browser, no third-party deps — just `urllib`.
|
|
22
|
+
|
|
23
|
+
The connector injects the cookie jar as a JSON env var `$CTO51_COOKIES`. Never
|
|
24
|
+
print it.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python3 "$SKILL_DIR/scripts/cto51.py" whoami
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If `$SKILL_DIR` points at a different skill loaded in the same turn, resolve
|
|
31
|
+
this skill's directory explicitly before running the commands below.
|
|
32
|
+
|
|
33
|
+
## Important: draft only
|
|
34
|
+
|
|
35
|
+
51CTO's write endpoint creates a **draft**. This skill returns the draft's
|
|
36
|
+
editor URL and does not publish. Tell the user plainly that they must open that
|
|
37
|
+
URL and publish themselves — do not claim the article went live.
|
|
38
|
+
|
|
39
|
+
## Verify the connection first
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python3 "$SKILL_DIR/scripts/cto51.py" whoami
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If this fails with a redirect or auth error, the cookie has expired. Ask the
|
|
46
|
+
user to reconnect at `https://auth.acedata.cloud/user/connections` rather than
|
|
47
|
+
retrying.
|
|
48
|
+
|
|
49
|
+
## Create a draft — GATED
|
|
50
|
+
|
|
51
|
+
Prepare the complete Markdown in a file. The first call is always a dry run and
|
|
52
|
+
does not write anything.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Dry run — shows exactly what would be written.
|
|
56
|
+
python3 "$SKILL_DIR/scripts/cto51.py" draft \
|
|
57
|
+
--title "标题" --content-file /tmp/article.md --tags "python,api"
|
|
58
|
+
|
|
59
|
+
# Actually create the draft after the user confirms.
|
|
60
|
+
python3 "$SKILL_DIR/scripts/cto51.py" draft \
|
|
61
|
+
--title "标题" --content-file /tmp/article.md --tags "python,api" --confirm
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Options: `--content-file <path.md>` (preferred) or `--content "<markdown>"` for
|
|
65
|
+
short inline text; `--tags "a,b"` comma-separated; `--abstract "…"` sets the
|
|
66
|
+
summary shown in listings. The dry run echoes every field that will be written,
|
|
67
|
+
including the abstract — show that output to the user before confirming.
|
|
68
|
+
|
|
69
|
+
`--confirm` is valid only as the final argument. Show the title, tags and full
|
|
70
|
+
content to the user before writing.
|
|
71
|
+
|
|
72
|
+
## Gotchas
|
|
73
|
+
|
|
74
|
+
- 51CTO sits behind a WAF that answers a bare request with HTTP 567. The CLI
|
|
75
|
+
always sends a full browser fingerprint, so do not strip its headers or
|
|
76
|
+
re-implement the calls with plain `curl`.
|
|
77
|
+
- Both the identity and the `_csrf` token come from the publish page. A
|
|
78
|
+
redirect there means the session is dead — reconnect, do not retry.
|
|
79
|
+
- Content is sent as Markdown (`is_old=0`). Do not pre-render it to HTML.
|
|
80
|
+
- Images referenced by external URL are not re-hosted. If the source host
|
|
81
|
+
blocks hotlinking they will not render; mention this when the article has
|
|
82
|
+
images.
|
|
83
|
+
- Do not retry a timed-out write automatically — the outcome may be unknown and
|
|
84
|
+
a retry can create a duplicate draft.
|
|
85
|
+
|
|
86
|
+
## Record the output
|
|
87
|
+
|
|
88
|
+
This skill only produces drafts, so do **not** call `publish_artifact`. Report
|
|
89
|
+
the returned `draft_id` and `edit_url` to the user instead.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cto51 — read & draft on 51CTO 博客 (blog.51cto.com) with the user's own login
|
|
4
|
+
cookies (BYOC). Standard-library only (urllib), no third-party deps, so it runs
|
|
5
|
+
in the bare sandbox without an image change.
|
|
6
|
+
|
|
7
|
+
The connector injects the user's cookie jar as a JSON env var ``CTO51_COOKIES``
|
|
8
|
+
— a list of ``{name, value, domain, ...}`` dicts captured by the ACE extension.
|
|
9
|
+
(The namespace is `cto51`, not `51cto`, because `51CTO_COOKIES` would not be a
|
|
10
|
+
valid shell identifier.)
|
|
11
|
+
|
|
12
|
+
Read commands run directly. ``draft`` is GATED: without a trailing ``--confirm``
|
|
13
|
+
it only dry-runs. ``--confirm`` is honored ONLY as the last argument.
|
|
14
|
+
|
|
15
|
+
NOTE: this creates a DRAFT and returns its editor URL; the user finishes
|
|
16
|
+
publishing in the 51CTO editor.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
python3 cto51.py whoami
|
|
20
|
+
python3 cto51.py draft --title T --content-file a.md --confirm
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import gzip
|
|
27
|
+
import http.client
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import socket
|
|
32
|
+
import sys
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
|
|
37
|
+
UA = (
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
39
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
40
|
+
)
|
|
41
|
+
PLATFORM = "cto51"
|
|
42
|
+
BASE = "https://blog.51cto.com"
|
|
43
|
+
PUBLISH_PAGE = f"{BASE}/blogger/publish"
|
|
44
|
+
# Cap on the FORM-ENCODED body (urlencode expands CJK ~3x).
|
|
45
|
+
MAX_ENCODED_BYTES = 8 * 1024 * 1024
|
|
46
|
+
|
|
47
|
+
# Bounded quantifiers so a hostile page cannot cause catastrophic backtracking.
|
|
48
|
+
_CSRF_RE = re.compile(r'<meta\s{1,10}name="csrf-token"\s{1,10}content="([^"]{1,500})"')
|
|
49
|
+
_USER_RE = re.compile(
|
|
50
|
+
r'<li class="more user">\s{0,50}<a[^>]{0,300}href="([^"]{1,300})"[^>]{0,300}>'
|
|
51
|
+
r'\s{0,50}<img[^>]{0,300}src="([^"]{1,300})"'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_RAW = sys.argv[1:]
|
|
55
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
56
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
60
|
+
"""Refuse redirects outright.
|
|
61
|
+
|
|
62
|
+
add_unredirected_header only protects the Cookie; urllib copies every other
|
|
63
|
+
header (here the `_csrf` form token's companion headers) onto the redirected
|
|
64
|
+
request, so a 30x to a foreign host could hand them over.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def out(obj) -> None:
|
|
75
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def die(msg: str, code: int = 1) -> None:
|
|
79
|
+
out({"error": msg})
|
|
80
|
+
sys.exit(code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# 51CTO error/WAF pages carry the site chrome, including the csrf-token meta —
|
|
84
|
+
# never echo a raw response body without stripping it. Covers both the HTML meta
|
|
85
|
+
# form (name="csrf-token" content="…") and any JSON/inline "csrfToken":"…".
|
|
86
|
+
_CSRF_LEAK_RES = (
|
|
87
|
+
# name="csrf-token" … content="TOKEN" (and the reversed attribute order)
|
|
88
|
+
re.compile(r'(name=["\']csrf[-_]?token["\'][^>]{0,400}?content=["\'])([^"\']{0,500})', re.I),
|
|
89
|
+
re.compile(r'(content=["\'])([^"\']{0,500})(["\'][^>]{0,400}?name=["\']csrf[-_]?token)', re.I),
|
|
90
|
+
# "_csrf": "TOKEN" / _csrf=TOKEN / csrfToken: 'TOKEN' — 51CTO runs Yii, whose
|
|
91
|
+
# CSRF parameter is literally `_csrf`, so `token` must be OPTIONAL here.
|
|
92
|
+
re.compile(r'(_?csrf[-_]?(?:token)?["\']?\s{0,5}[:=]\s{0,5}["\']?)([^"\'&,\s>;]{1,500})', re.I),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _redact(text: str) -> str:
|
|
97
|
+
for rx in _CSRF_LEAK_RES:
|
|
98
|
+
text = rx.sub(
|
|
99
|
+
(lambda mo: mo.group(1) + "<redacted>" + (mo.group(3) if mo.lastindex and mo.lastindex >= 3 else "")),
|
|
100
|
+
text,
|
|
101
|
+
)
|
|
102
|
+
return text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Cookie jar (shared pattern across the cookie-BYOC skills) ────────
|
|
107
|
+
|
|
108
|
+
def load_cookies() -> list:
|
|
109
|
+
env = f"{PLATFORM.upper()}_COOKIES"
|
|
110
|
+
raw = os.environ.get(env)
|
|
111
|
+
if not raw:
|
|
112
|
+
die(f"{env} is not set — connect 51CTO at "
|
|
113
|
+
f"https://auth.acedata.cloud/user/connections, then retry.")
|
|
114
|
+
try:
|
|
115
|
+
jar = json.loads(raw)
|
|
116
|
+
except json.JSONDecodeError as e:
|
|
117
|
+
die(f"{env} is not valid JSON: {e}")
|
|
118
|
+
if not isinstance(jar, list):
|
|
119
|
+
die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
|
|
120
|
+
return jar
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
124
|
+
d = domain.lstrip(".").lower()
|
|
125
|
+
h = host.lower()
|
|
126
|
+
return not d or h == d or h.endswith("." + d)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
130
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
131
|
+
host_in_scope = any(
|
|
132
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
133
|
+
)
|
|
134
|
+
parts = []
|
|
135
|
+
for c in jar:
|
|
136
|
+
name, value = c.get("name"), c.get("value")
|
|
137
|
+
if not name or value is None:
|
|
138
|
+
continue
|
|
139
|
+
domain = c.get("domain")
|
|
140
|
+
if domain:
|
|
141
|
+
if not _domain_matches(host, str(domain)):
|
|
142
|
+
continue
|
|
143
|
+
elif not host_in_scope:
|
|
144
|
+
continue
|
|
145
|
+
# http.client raises ValueError("Invalid header value %r" % value) at
|
|
146
|
+
# send time for CR/LF, and UnicodeEncodeError for non-latin1 — and the
|
|
147
|
+
# exception text carries the WHOLE Cookie header. Reject here, with a
|
|
148
|
+
# message that never echoes a value.
|
|
149
|
+
pair = f"{name}={value}"
|
|
150
|
+
if any(ch in pair for ch in "\r\n\x00"):
|
|
151
|
+
die("a cookie in the jar contains a line break and cannot be sent — "
|
|
152
|
+
"reconnect at https://auth.acedata.cloud/user/connections.")
|
|
153
|
+
try:
|
|
154
|
+
pair.encode("latin-1")
|
|
155
|
+
except UnicodeEncodeError:
|
|
156
|
+
die("a cookie in the jar contains characters that cannot be sent in "
|
|
157
|
+
"an HTTP header — reconnect at "
|
|
158
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
159
|
+
parts.append(pair)
|
|
160
|
+
return "; ".join(parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def request(method: str, url: str, jar: list, *, headers=None, form=None,
|
|
164
|
+
write: bool = False):
|
|
165
|
+
# 51CTO's WAF 567s a bare request; it needs a full browser fingerprint
|
|
166
|
+
# (same treatment the csdn skill needs).
|
|
167
|
+
hdrs = {
|
|
168
|
+
"User-Agent": UA,
|
|
169
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
170
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
171
|
+
"sec-ch-ua": '"Chromium";v="131", "Not_A Brand";v="24"',
|
|
172
|
+
"sec-ch-ua-mobile": "?0",
|
|
173
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
174
|
+
"Sec-Fetch-Dest": "document",
|
|
175
|
+
"Sec-Fetch-Mode": "navigate",
|
|
176
|
+
"Sec-Fetch-Site": "same-origin",
|
|
177
|
+
"Origin": BASE,
|
|
178
|
+
"Referer": PUBLISH_PAGE,
|
|
179
|
+
}
|
|
180
|
+
if headers:
|
|
181
|
+
hdrs.update(headers)
|
|
182
|
+
data = None
|
|
183
|
+
if form is not None:
|
|
184
|
+
data = urllib.parse.urlencode(form).encode("utf-8")
|
|
185
|
+
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
|
186
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
187
|
+
# Unredirected → the cookie is not re-sent if the API 30x-redirects to a
|
|
188
|
+
# different host (e.g. a login page), so the jar never leaks off-site.
|
|
189
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
190
|
+
try:
|
|
191
|
+
with _OPENER.open(req, timeout=30) as resp:
|
|
192
|
+
raw = resp.read()
|
|
193
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
194
|
+
raw = gzip.decompress(raw)
|
|
195
|
+
return resp.status, raw.decode("utf-8", "replace")
|
|
196
|
+
except urllib.error.HTTPError as e:
|
|
197
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
198
|
+
die("51CTO redirected the request — not followed, so no credential "
|
|
199
|
+
"left 51cto.com. You are most likely logged out; reconnect at "
|
|
200
|
+
"https://auth.acedata.cloud/user/connections."
|
|
201
|
+
+ (" This was a WRITE: its outcome is UNKNOWN — check your "
|
|
202
|
+
"drafts before retrying." if write else ""))
|
|
203
|
+
# Draining the error body can itself raise (IncompleteRead / reset).
|
|
204
|
+
# Degrade to an empty body so a truncated 5xx still flows into the
|
|
205
|
+
# normal non-JSON path, which carries the write-UNKNOWN wording.
|
|
206
|
+
try:
|
|
207
|
+
raw = e.read()
|
|
208
|
+
if e.headers.get("Content-Encoding") == "gzip":
|
|
209
|
+
raw = gzip.decompress(raw)
|
|
210
|
+
except Exception:
|
|
211
|
+
raw = b""
|
|
212
|
+
return e.code, raw.decode("utf-8", "replace")
|
|
213
|
+
# URLError subclasses OSError, so it must come first. The receive phase
|
|
214
|
+
# (getresponse) raises TimeoutError/HTTPException OUTSIDE urllib's own
|
|
215
|
+
# OSError wrapper, so a write whose reply never lands would otherwise
|
|
216
|
+
# escape as a bare traceback with no JSON at all.
|
|
217
|
+
except (urllib.error.URLError, TimeoutError, socket.timeout,
|
|
218
|
+
http.client.HTTPException, gzip.BadGzipFile, OSError) as e:
|
|
219
|
+
if write:
|
|
220
|
+
die(f"51CTO write did not return a result ({type(e).__name__}: {e}); "
|
|
221
|
+
f"the outcome is UNKNOWN. Check your 51CTO drafts before "
|
|
222
|
+
f"retrying so you do not create a duplicate.")
|
|
223
|
+
die(f"network error reaching {url}: {type(e).__name__}: {e}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def publish_page(jar: list) -> tuple[str, dict]:
|
|
227
|
+
"""Fetch the publish page — it carries both the identity and the CSRF token."""
|
|
228
|
+
status, html = request("GET", PUBLISH_PAGE, jar)
|
|
229
|
+
if status in (401, 403):
|
|
230
|
+
die("auth failed — cookie expired or invalid. Reconnect at "
|
|
231
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
232
|
+
if status != 200:
|
|
233
|
+
die(f"unexpected status {status} loading the 51CTO publish page")
|
|
234
|
+
m = _USER_RE.search(html)
|
|
235
|
+
if not m:
|
|
236
|
+
# The csrf-token meta also appears on logged-out pages, so it is not a
|
|
237
|
+
# session signal. Distinguish "definitely logged out" from "markup
|
|
238
|
+
# changed" instead of always blaming the cookie.
|
|
239
|
+
if "/user/login" in html or "home.51cto.com/login" in html:
|
|
240
|
+
die("not logged in to 51CTO — reconnect at "
|
|
241
|
+
"https://auth.acedata.cloud/user/connections.")
|
|
242
|
+
die("could not confirm the 51CTO session from the publish page — either "
|
|
243
|
+
"the cookie expired or 51CTO changed its markup. Try reconnecting "
|
|
244
|
+
"at https://auth.acedata.cloud/user/connections; if that does not "
|
|
245
|
+
"help, this skill needs updating.")
|
|
246
|
+
link, avatar = m.group(1), m.group(2)
|
|
247
|
+
csrf_m = _CSRF_RE.search(html)
|
|
248
|
+
if not csrf_m:
|
|
249
|
+
die("could not read the 51CTO CSRF token from the publish page; "
|
|
250
|
+
"reconnect and retry.")
|
|
251
|
+
uid = link.rstrip("/").split("/")[-1]
|
|
252
|
+
return csrf_m.group(1), {"user_id": uid, "url": link, "avatar": avatar}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── commands ────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def cmd_whoami(jar, _args):
|
|
258
|
+
_csrf, who = publish_page(jar)
|
|
259
|
+
out({"platform": PLATFORM, **who})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def read_content(args) -> str:
|
|
263
|
+
content = args.content
|
|
264
|
+
if args.content_file:
|
|
265
|
+
try:
|
|
266
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
267
|
+
content = f.read()
|
|
268
|
+
except OSError as e:
|
|
269
|
+
die(f"cannot read --content-file: {e}")
|
|
270
|
+
if content is None:
|
|
271
|
+
die("provide --content-file <path.md> or --content <markdown>")
|
|
272
|
+
# The body is form-urlencoded, which expands each CJK byte to %XX (~3x), so
|
|
273
|
+
# cap the ENCODED size — a raw-byte cap would let a 10 MiB Chinese article
|
|
274
|
+
# become a ~30 MB POST that 51CTO rejects opaquely mid-write.
|
|
275
|
+
encoded = len(urllib.parse.quote_plus(content))
|
|
276
|
+
if encoded > MAX_ENCODED_BYTES:
|
|
277
|
+
die(f"content is too large: {encoded} bytes once form-encoded "
|
|
278
|
+
f"(limit {MAX_ENCODED_BYTES}). Split the article or trim it.")
|
|
279
|
+
return content
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_draft(jar, args):
|
|
283
|
+
if not args.title:
|
|
284
|
+
die("--title is required")
|
|
285
|
+
content = read_content(args)
|
|
286
|
+
tags = ",".join(t.strip() for t in (args.tags or "").split(",") if t.strip())
|
|
287
|
+
|
|
288
|
+
if not CONFIRM:
|
|
289
|
+
out({
|
|
290
|
+
"dry_run": True, "command": "draft", "platform": PLATFORM,
|
|
291
|
+
"title": args.title, "tags": tags,
|
|
292
|
+
"abstract": args.abstract or "",
|
|
293
|
+
"content_characters": len(content),
|
|
294
|
+
"note": "51CTO content is Markdown (is_old=0). Re-run with --confirm "
|
|
295
|
+
"as the LAST argument to actually create the draft. This "
|
|
296
|
+
"creates a DRAFT — finish publishing in the 51CTO editor.",
|
|
297
|
+
})
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
csrf, _who = publish_page(jar)
|
|
301
|
+
# We hold the exact token, so scrub it by value too — airtight regardless of
|
|
302
|
+
# how 51CTO frames it in an error page (the patterns are defence in depth).
|
|
303
|
+
def scrub(text: str) -> str:
|
|
304
|
+
return _redact(text.replace(csrf, "<redacted>") if csrf else text)
|
|
305
|
+
form = {
|
|
306
|
+
"title": args.title,
|
|
307
|
+
"content": content,
|
|
308
|
+
"cate_id": "",
|
|
309
|
+
"custom_id": "0",
|
|
310
|
+
"tag": tags,
|
|
311
|
+
"abstract": args.abstract or "",
|
|
312
|
+
"banner_type": "0",
|
|
313
|
+
"blog_type": "1",
|
|
314
|
+
"copy_code": "1",
|
|
315
|
+
"is_hide": "0",
|
|
316
|
+
"top_time": "0",
|
|
317
|
+
"is_comment": "0",
|
|
318
|
+
"is_old": "0", # 0 = Markdown
|
|
319
|
+
"blog_id": "",
|
|
320
|
+
"pid": "",
|
|
321
|
+
"did": "",
|
|
322
|
+
"work_id": "",
|
|
323
|
+
"class_id": "",
|
|
324
|
+
"subjectId": "",
|
|
325
|
+
"import_type": "-1",
|
|
326
|
+
"invite_code": "",
|
|
327
|
+
"raffle": "",
|
|
328
|
+
"orig": "",
|
|
329
|
+
"_csrf": csrf,
|
|
330
|
+
}
|
|
331
|
+
status, text = request("POST", f"{BASE}/blogger/draft", jar, write=True, form=form,
|
|
332
|
+
headers={
|
|
333
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
334
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
335
|
+
"Sec-Fetch-Dest": "empty",
|
|
336
|
+
"Sec-Fetch-Mode": "cors",
|
|
337
|
+
})
|
|
338
|
+
try:
|
|
339
|
+
res = json.loads(text)
|
|
340
|
+
except json.JSONDecodeError:
|
|
341
|
+
die(f"51CTO write returned a non-JSON response ({status}); the outcome "
|
|
342
|
+
f"is UNKNOWN. Check your drafts before retrying. "
|
|
343
|
+
f"Body: {scrub(text)[:200]}")
|
|
344
|
+
# The endpoint really does answer `"data": []` (a list) for the logged-out
|
|
345
|
+
# case, so never dereference it without checking the shape first.
|
|
346
|
+
if not isinstance(res, dict):
|
|
347
|
+
die(f"51CTO write returned an unexpected response shape ({status}); the "
|
|
348
|
+
f"outcome is UNKNOWN. Check your drafts before retrying.")
|
|
349
|
+
data = res.get("data")
|
|
350
|
+
if res.get("status") != 1 or not isinstance(data, dict):
|
|
351
|
+
# Normalize to JSON, never repr(): a dict/list `msg` rendered with
|
|
352
|
+
# repr() uses single quotes, which _redact's double-quote-anchored
|
|
353
|
+
# patterns cannot match.
|
|
354
|
+
raw_msg = res.get("msg")
|
|
355
|
+
detail = (raw_msg if isinstance(raw_msg, str)
|
|
356
|
+
else json.dumps(raw_msg if raw_msg is not None else res,
|
|
357
|
+
ensure_ascii=False))
|
|
358
|
+
die(f"draft creation failed: {scrub(detail)[:200]}")
|
|
359
|
+
# Only an ASCII-numeric id is a real draft handle. str.isdigit() alone is
|
|
360
|
+
# Unicode-wide ('١٢٣', '²' all pass) and would build a plausible-but-wrong
|
|
361
|
+
# edit_url while reporting ok=true.
|
|
362
|
+
did = str(data.get("did") or "").strip()
|
|
363
|
+
if not re.fullmatch(r"[0-9]{1,20}", did):
|
|
364
|
+
# Redact BEFORE slicing so a token can never be half-exposed by the cut.
|
|
365
|
+
die(f"draft creation returned an unusable id {scrub(did)[:80]!r}; the "
|
|
366
|
+
f"outcome is UNKNOWN — check your 51CTO drafts before retrying. "
|
|
367
|
+
f"{scrub(json.dumps(res, ensure_ascii=False))[:200]}")
|
|
368
|
+
out({
|
|
369
|
+
"ok": True,
|
|
370
|
+
"draft_only": True,
|
|
371
|
+
"draft_id": str(did),
|
|
372
|
+
"edit_url": f"{BASE}/blogger/draft/{did}",
|
|
373
|
+
"note": "Draft saved. Open edit_url to review and publish.",
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
COMMANDS = {
|
|
378
|
+
"whoami": cmd_whoami,
|
|
379
|
+
"draft": cmd_draft,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main() -> None:
|
|
384
|
+
p = argparse.ArgumentParser(prog="cto51.py", description="51CTO 博客 cookie CLI")
|
|
385
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
386
|
+
sub.add_parser("whoami", help="show the logged-in account")
|
|
387
|
+
sp = sub.add_parser("draft", help="create a draft article (GATED by trailing --confirm)")
|
|
388
|
+
sp.add_argument("--title")
|
|
389
|
+
sp.add_argument("--content", help="Markdown content inline")
|
|
390
|
+
sp.add_argument("--content-file", help="path to a Markdown file")
|
|
391
|
+
sp.add_argument("--tags", help="comma-separated tag names")
|
|
392
|
+
sp.add_argument("--abstract", help="short summary shown in listings")
|
|
393
|
+
args = p.parse_args(ARGV)
|
|
394
|
+
jar = load_cookies()
|
|
395
|
+
COMMANDS[args.command](jar, args)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
# The agent consuming this CLI parses stdout as JSON — never let an
|
|
400
|
+
# unexpected exception escape as a bare traceback with empty stdout.
|
|
401
|
+
try:
|
|
402
|
+
main()
|
|
403
|
+
except SystemExit:
|
|
404
|
+
raise
|
|
405
|
+
except BaseException as exc: # noqa: BLE001
|
|
406
|
+
die(f"unexpected {type(exc).__name__}: {_redact(str(exc))[:200]}")
|
package/skills/reddit/SKILL.md
CHANGED
|
@@ -52,8 +52,12 @@ R="${SKILL_DIR:-}/scripts/reddit.py"; [ -f "$R" ] || R=$(find /tmp -maxdepth 8 -
|
|
|
52
52
|
[ -f "$R" ] || { echo "reddit script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
|
|
53
53
|
python3 "$R" whoami
|
|
54
54
|
python3 "$R" submissions --limit 10
|
|
55
|
+
python3 "$R" comments --limit 10
|
|
55
56
|
```
|
|
56
57
|
|
|
58
|
+
If a `comment` write ever reports an unknown outcome, run `comments` to check
|
|
59
|
+
whether it actually landed **before** considering any retry.
|
|
60
|
+
|
|
57
61
|
## Find threads and check the rules
|
|
58
62
|
|
|
59
63
|
`search` returns each hit's `fullname` (`t3_…`), which is what `comment --parent`
|
|
@@ -474,6 +474,26 @@ class RedditClient:
|
|
|
474
474
|
return {"ok": True, "posted": True, "id": post.get("id"), "name": post.get("name"), "url": post_url}
|
|
475
475
|
|
|
476
476
|
|
|
477
|
+
def comments(self, limit: int) -> list[dict]:
|
|
478
|
+
"""List my own recent comments — used to verify an ambiguous write."""
|
|
479
|
+
username = self.username or str(self.me().get("name") or "")
|
|
480
|
+
suffix = ".json" if self.mode == "cookie" else ""
|
|
481
|
+
payload = self.request(
|
|
482
|
+
"GET",
|
|
483
|
+
f"/user/{urllib.parse.quote(username, safe='')}/comments{suffix}",
|
|
484
|
+
query={"limit": limit, "raw_json": 1},
|
|
485
|
+
)
|
|
486
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
|
|
487
|
+
die("Reddit returned a malformed comments response; authenticated response content was omitted.")
|
|
488
|
+
children = payload["data"].get("children")
|
|
489
|
+
# Fail closed: a silently-empty list reads as "the write did not land"
|
|
490
|
+
# and invites the duplicate reply this command exists to prevent.
|
|
491
|
+
if not isinstance(children, list) or any(
|
|
492
|
+
not isinstance(child, dict) or not valid_comment_data(child.get("data")) for child in children
|
|
493
|
+
):
|
|
494
|
+
die("Reddit returned a malformed comments response; authenticated response content was omitted.")
|
|
495
|
+
return [child["data"] for child in children]
|
|
496
|
+
|
|
477
497
|
def search(self, *, query: str, subreddit: str = "", sort: str = "relevance", limit: int = 10, time_filter: str = "month") -> list[dict]:
|
|
478
498
|
suffix = ".json" if self.mode == "cookie" else ""
|
|
479
499
|
path = f"/r/{subreddit}/search{suffix}" if subreddit else f"/search{suffix}"
|
|
@@ -540,11 +560,37 @@ class RedditClient:
|
|
|
540
560
|
created = things[0].get("data")
|
|
541
561
|
if not isinstance(created, dict):
|
|
542
562
|
die_unknown_comment_response()
|
|
563
|
+
comment_name = created.get("name")
|
|
564
|
+
comment_id = created.get("id")
|
|
565
|
+
if not isinstance(comment_id, str) or not comment_id:
|
|
566
|
+
# `name` is the fullname (t1_<id>), so it carries the id too.
|
|
567
|
+
comment_id = comment_name[3:] if isinstance(comment_name, str) and comment_name.startswith("t1_") else ""
|
|
568
|
+
|
|
569
|
+
# A permalink alone proves creation — accept it even without an id.
|
|
543
570
|
permalink = created.get("permalink")
|
|
544
|
-
|
|
545
|
-
|
|
571
|
+
if isinstance(permalink, str) and permalink.startswith("/"):
|
|
572
|
+
return {"ok": True, "commented": True, "id": comment_id or None, "name": comment_name, "url": WEB_BASE + permalink}
|
|
573
|
+
|
|
574
|
+
if not comment_id:
|
|
546
575
|
die_unknown_comment_response()
|
|
547
|
-
|
|
576
|
+
|
|
577
|
+
# link_id is the thread even when replying to a comment (t1_ parent).
|
|
578
|
+
link_id = created.get("link_id")
|
|
579
|
+
thread = link_id[3:] if isinstance(link_id, str) and link_id.startswith("t3_") else ""
|
|
580
|
+
if not thread and parent.startswith("t3_"):
|
|
581
|
+
thread = parent[3:]
|
|
582
|
+
if not thread:
|
|
583
|
+
# Created for sure (we hold its id) but the thread is unknown; never
|
|
584
|
+
# report this as a failed write — that is what invites a duplicate.
|
|
585
|
+
return {
|
|
586
|
+
"ok": True,
|
|
587
|
+
"commented": True,
|
|
588
|
+
"id": comment_id,
|
|
589
|
+
"name": comment_name,
|
|
590
|
+
"url": None,
|
|
591
|
+
"note": "Reddit did not return a permalink. Run `comments` to locate it; do not resend.",
|
|
592
|
+
}
|
|
593
|
+
return {"ok": True, "commented": True, "id": comment_id, "name": comment_name, "url": derive_comment_url(thread, comment_id)}
|
|
548
594
|
|
|
549
595
|
|
|
550
596
|
def format_profile(data: dict, mode: str) -> dict:
|
|
@@ -584,10 +630,41 @@ def die_unknown_post_response() -> None:
|
|
|
584
630
|
die_unknown_write_outcome("Reddit returned a malformed write response")
|
|
585
631
|
|
|
586
632
|
|
|
633
|
+
COMMENT_ID_RE = re.compile(r"^[A-Za-z0-9]{2,16}$")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def derive_comment_url(thread_id: str, comment_id: str) -> str:
|
|
637
|
+
"""Build Reddit's slug-less permalink when the write response omits one."""
|
|
638
|
+
if not COMMENT_ID_RE.fullmatch(thread_id) or not COMMENT_ID_RE.fullmatch(comment_id):
|
|
639
|
+
die_unknown_comment_response()
|
|
640
|
+
return f"{WEB_BASE}/comments/{thread_id}/_/{comment_id}/"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def valid_comment_data(item: object) -> bool:
|
|
644
|
+
if not isinstance(item, dict):
|
|
645
|
+
return False
|
|
646
|
+
if not isinstance(item.get("id"), str) or not item["id"]:
|
|
647
|
+
return False
|
|
648
|
+
return isinstance(item.get("body"), str) and isinstance(item.get("permalink"), (str, type(None)))
|
|
649
|
+
|
|
650
|
+
|
|
587
651
|
def die_unknown_comment_response() -> None:
|
|
588
652
|
die_unknown_write_outcome("Reddit returned a malformed comment response", kind="comment")
|
|
589
653
|
|
|
590
654
|
|
|
655
|
+
def format_comment(item: dict) -> dict:
|
|
656
|
+
permalink = item.get("permalink")
|
|
657
|
+
return {
|
|
658
|
+
"id": item.get("id"),
|
|
659
|
+
"body": str(item.get("body") or "")[:300],
|
|
660
|
+
"link_title": item.get("link_title"),
|
|
661
|
+
"subreddit": item.get("subreddit"),
|
|
662
|
+
"url": WEB_BASE + permalink if isinstance(permalink, str) and permalink.startswith("/") else None,
|
|
663
|
+
"score": item.get("score"),
|
|
664
|
+
"created_utc": item.get("created_utc"),
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
|
|
591
668
|
def format_search_hit(item: dict) -> dict:
|
|
592
669
|
permalink = item.get("permalink")
|
|
593
670
|
return {
|
|
@@ -674,6 +751,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
674
751
|
link_post.add_argument("--title", required=True)
|
|
675
752
|
link_post.add_argument("--url", required=True)
|
|
676
753
|
|
|
754
|
+
my_comments = commands.add_parser("comments", help="list my recent comments (verify an ambiguous write)")
|
|
755
|
+
my_comments.add_argument("--limit", type=positive_limit, default=10)
|
|
756
|
+
|
|
677
757
|
search = commands.add_parser("search", help="search public posts to find threads worth replying to")
|
|
678
758
|
search.add_argument("--query", "-q", required=True)
|
|
679
759
|
search.add_argument("--subreddit", "-r", default="")
|
|
@@ -709,6 +789,10 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
709
789
|
items = client.submissions(args.limit)
|
|
710
790
|
output({"auth_mode": client.mode, "count": len(items), "submissions": [format_submission(item) for item in items]})
|
|
711
791
|
return
|
|
792
|
+
if args.command == "comments":
|
|
793
|
+
items = client.comments(args.limit)
|
|
794
|
+
output({"auth_mode": client.mode, "count": len(items), "comments": [format_comment(item) for item in items]})
|
|
795
|
+
return
|
|
712
796
|
if args.command == "search":
|
|
713
797
|
hits = client.search(
|
|
714
798
|
query=validate_query(args.query),
|
|
@@ -468,7 +468,8 @@ class RedditSkillTests(unittest.TestCase):
|
|
|
468
468
|
{"json": {"errors": [["RATELIMIT", "you are doing that too much csrf-secret", None]]}},
|
|
469
469
|
{"json": {"errors": []}},
|
|
470
470
|
{"json": {"errors": [], "data": {"things": []}}},
|
|
471
|
-
|
|
471
|
+
# No id at all -> genuinely unknown outcome.
|
|
472
|
+
{"json": {"errors": [], "data": {"things": [{"data": {"body": "x"}}]}}},
|
|
472
473
|
]
|
|
473
474
|
for payload in payloads:
|
|
474
475
|
stream = io.StringIO()
|
|
@@ -478,6 +479,136 @@ class RedditSkillTests(unittest.TestCase):
|
|
|
478
479
|
client.comment(parent="t3_post1", body="Body")
|
|
479
480
|
self.assertNotIn("csrf-secret", stream.getvalue())
|
|
480
481
|
|
|
482
|
+
def test_comment_without_permalink_is_a_success_not_an_unknown_outcome(self):
|
|
483
|
+
"""Cookie mode often omits permalink; an id means Reddit created the comment."""
|
|
484
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
485
|
+
client.modhash = "csrf-secret"
|
|
486
|
+
payload = {
|
|
487
|
+
"json": {"errors": [], "data": {"things": [{"data": {"id": "nabc123", "name": "t1_nabc123"}}]}}
|
|
488
|
+
}
|
|
489
|
+
with patch.object(client, "request", return_value=payload):
|
|
490
|
+
result = client.comment(parent="t3_1v711cj", body="Looks good")
|
|
491
|
+
|
|
492
|
+
self.assertTrue(result["commented"])
|
|
493
|
+
self.assertEqual("nabc123", result["id"])
|
|
494
|
+
self.assertEqual("https://www.reddit.com/comments/1v711cj/_/nabc123/", result["url"])
|
|
495
|
+
|
|
496
|
+
def test_comments_fails_closed_on_drifted_shape(self):
|
|
497
|
+
"""A silently-empty list would read as 'the write did not land' -> duplicate reply."""
|
|
498
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
499
|
+
client.username = "tester"
|
|
500
|
+
drifted = {"data": {"children": [{"kind": "t1", "comment": {"id": "c1", "body": "x"}}]}}
|
|
501
|
+
stream = io.StringIO()
|
|
502
|
+
with patch.object(client, "request", return_value=drifted), self.assertRaises(
|
|
503
|
+
SystemExit
|
|
504
|
+
), redirect_stdout(stream):
|
|
505
|
+
client.comments(10)
|
|
506
|
+
self.assertIn("malformed comments response", stream.getvalue())
|
|
507
|
+
|
|
508
|
+
def test_comments_accepts_documented_shape_and_survives_odd_body(self):
|
|
509
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
510
|
+
client.username = "tester"
|
|
511
|
+
payload = {
|
|
512
|
+
"data": {
|
|
513
|
+
"children": [
|
|
514
|
+
{
|
|
515
|
+
"kind": "t1",
|
|
516
|
+
"data": {
|
|
517
|
+
"id": "c1",
|
|
518
|
+
"body": "Looks good",
|
|
519
|
+
"permalink": "/r/test/comments/p1/t/c1/",
|
|
520
|
+
"link_title": "Test",
|
|
521
|
+
"subreddit": "test",
|
|
522
|
+
},
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
with patch.object(client, "request", return_value=payload):
|
|
528
|
+
items = client.comments(10)
|
|
529
|
+
self.assertEqual(1, len(items))
|
|
530
|
+
formatted = reddit.format_comment(items[0])
|
|
531
|
+
self.assertEqual("Looks good", formatted["body"])
|
|
532
|
+
self.assertEqual("https://www.reddit.com/r/test/comments/p1/t/c1/", formatted["url"])
|
|
533
|
+
|
|
534
|
+
def test_reply_to_a_comment_derives_a_real_permalink_from_link_id(self):
|
|
535
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
536
|
+
client.modhash = "csrf-secret"
|
|
537
|
+
payload = {
|
|
538
|
+
"json": {
|
|
539
|
+
"errors": [],
|
|
540
|
+
"data": {"things": [{"data": {"id": "nc2", "name": "t1_nc2", "link_id": "t3_1v711cj"}}]},
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
with patch.object(client, "request", return_value=payload):
|
|
544
|
+
result = client.comment(parent="t1_parent1", body="Reply")
|
|
545
|
+
self.assertEqual("https://www.reddit.com/comments/1v711cj/_/nc2/", result["url"])
|
|
546
|
+
|
|
547
|
+
def test_derive_comment_url_rejects_ids_that_could_escape_reddit(self):
|
|
548
|
+
for thread, comment_id in [
|
|
549
|
+
("abc123", "x/../../../@evil.example.com"),
|
|
550
|
+
("../../evil", "c1"),
|
|
551
|
+
("abc123", ""),
|
|
552
|
+
]:
|
|
553
|
+
stream = io.StringIO()
|
|
554
|
+
with self.subTest(thread=thread, comment_id=comment_id), self.assertRaises(
|
|
555
|
+
SystemExit
|
|
556
|
+
), redirect_stdout(stream):
|
|
557
|
+
reddit.derive_comment_url(thread, comment_id)
|
|
558
|
+
|
|
559
|
+
def test_comment_accepts_permalink_only_and_name_only_responses(self):
|
|
560
|
+
"""Proof of creation in any form must never be reported as unknown."""
|
|
561
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
562
|
+
client.modhash = "csrf-secret"
|
|
563
|
+
|
|
564
|
+
# permalink but no id
|
|
565
|
+
payload = {"json": {"errors": [], "data": {"things": [
|
|
566
|
+
{"data": {"name": "t1_nabc12", "permalink": "/r/test/comments/1v711cj/slug/nabc12/"}}]}}}
|
|
567
|
+
with patch.object(client, "request", return_value=payload):
|
|
568
|
+
result = client.comment(parent="t3_1v711cj", body="B")
|
|
569
|
+
self.assertTrue(result["commented"])
|
|
570
|
+
self.assertEqual("https://www.reddit.com/r/test/comments/1v711cj/slug/nabc12/", result["url"])
|
|
571
|
+
|
|
572
|
+
# name but no id and no permalink
|
|
573
|
+
payload = {"json": {"errors": [], "data": {"things": [
|
|
574
|
+
{"data": {"name": "t1_nabc12", "link_id": "t3_1v711cj"}}]}}}
|
|
575
|
+
with patch.object(client, "request", return_value=payload):
|
|
576
|
+
result = client.comment(parent="t1_x1", body="B")
|
|
577
|
+
self.assertTrue(result["commented"])
|
|
578
|
+
self.assertEqual("https://www.reddit.com/comments/1v711cj/_/nabc12/", result["url"])
|
|
579
|
+
|
|
580
|
+
def test_comment_with_unknown_thread_still_reports_success(self):
|
|
581
|
+
"""We hold the id, so it was created; a 'failure' here would invite a duplicate."""
|
|
582
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
583
|
+
client.modhash = "csrf-secret"
|
|
584
|
+
payload = {"json": {"errors": [], "data": {"things": [{"data": {"id": "nc9", "name": "t1_nc9"}}]}}}
|
|
585
|
+
with patch.object(client, "request", return_value=payload):
|
|
586
|
+
result = client.comment(parent="t1_parent1", body="Reply")
|
|
587
|
+
self.assertTrue(result["commented"])
|
|
588
|
+
self.assertEqual("nc9", result["id"])
|
|
589
|
+
self.assertIsNone(result["url"])
|
|
590
|
+
self.assertIn("do not resend", result["note"])
|
|
591
|
+
|
|
592
|
+
def test_valid_comment_data_rejects_bad_types_but_allows_null_permalink(self):
|
|
593
|
+
self.assertTrue(reddit.valid_comment_data({"id": "c1", "body": "x", "permalink": None}))
|
|
594
|
+
self.assertTrue(reddit.valid_comment_data({"id": "c1", "body": "[deleted]"}))
|
|
595
|
+
self.assertFalse(reddit.valid_comment_data({"id": "c1", "body": {"nested": 1}}))
|
|
596
|
+
self.assertFalse(reddit.valid_comment_data({"id": "c1", "body": 123}))
|
|
597
|
+
self.assertFalse(reddit.valid_comment_data({"id": "", "body": "x"}))
|
|
598
|
+
self.assertFalse(reddit.valid_comment_data({"body": "x"}))
|
|
599
|
+
|
|
600
|
+
def test_comments_listing_survives_a_null_permalink_sibling(self):
|
|
601
|
+
client = reddit.RedditClient("cookie", cookies=reddit.parse_cookie_jar(COOKIE_JAR))
|
|
602
|
+
client.username = "tester"
|
|
603
|
+
payload = {"data": {"children": [
|
|
604
|
+
{"data": {"id": "c1", "body": "Looks good", "permalink": "/r/test/comments/p1/t/c1/"}},
|
|
605
|
+
{"data": {"id": "c2", "body": "older", "permalink": None}},
|
|
606
|
+
]}}
|
|
607
|
+
with patch.object(client, "request", return_value=payload):
|
|
608
|
+
items = client.comments(10)
|
|
609
|
+
self.assertEqual(2, len(items))
|
|
610
|
+
self.assertIsNone(reddit.format_comment(items[1])["url"])
|
|
611
|
+
|
|
481
612
|
def test_comment_body_limits_are_enforced(self):
|
|
482
613
|
for body in ["", " ", "x" * 10_001]:
|
|
483
614
|
stream = io.StringIO()
|