@acedatacloud/skills 2026.726.9 → 2026.726.11
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.11",
|
|
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/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()
|
|
@@ -334,17 +334,23 @@ class _ImgFinder(_HTMLParser):
|
|
|
334
334
|
text = self.get_starttag_text() or ""
|
|
335
335
|
line, col = self.getpos()
|
|
336
336
|
start = self._line_starts[line - 1] + col
|
|
337
|
+
# If the computed offset doesn't land on the tag we were handed, the
|
|
338
|
+
# line mapping is wrong and splicing would corrupt the article. Refuse
|
|
339
|
+
# rather than write a mangled body.
|
|
340
|
+
if self._html[start:start + len(text)] != text:
|
|
341
|
+
raise ValueError(f"offset mismatch at line {line} col {col}")
|
|
337
342
|
self.spans.append((start, start + len(text), attrs))
|
|
338
343
|
|
|
339
344
|
handle_starttag = _record
|
|
340
345
|
handle_startendtag = _record
|
|
341
346
|
|
|
342
347
|
def find(self, html):
|
|
343
|
-
# getpos() is (line, col);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
# getpos() is (line, col); map that to an index. HTMLParser counts
|
|
349
|
+
# lines with rawdata.count("\n"), so index on "\n" ONLY — str.splitlines
|
|
350
|
+
# also breaks on \r, \v, \f, \x1c-\x1e, \x85,
,
, and every
|
|
351
|
+
# such character would shift every subsequent offset.
|
|
352
|
+
self._html = html
|
|
353
|
+
self._line_starts = [0] + [mt.end() for mt in re.finditer("\n", html)]
|
|
348
354
|
self.feed(html)
|
|
349
355
|
self.close()
|
|
350
356
|
return self.spans
|
|
@@ -490,6 +496,22 @@ def rehost_images(jar, html, drop_failed=False):
|
|
|
490
496
|
failures.append(f"{src[:80]} ({e})")
|
|
491
497
|
out.append(html[cursor:])
|
|
492
498
|
result = "".join(out)
|
|
499
|
+
# Backstop: the tokenizer silently sees nothing inside an unterminated tag
|
|
500
|
+
# or an unterminated <script>/<style>/<textarea>/<title>, so an external
|
|
501
|
+
# <img> there would ship verbatim and 头条 rejects the whole article (7115).
|
|
502
|
+
# Our own rewrites all carry web_uri=, so any other <img left in the output
|
|
503
|
+
# is one we never processed. Fatal even under drop_failed — a tag the
|
|
504
|
+
# parser cannot see is also one we cannot remove.
|
|
505
|
+
unseen = [seg[:120] for seg in re.split(r"(?i)(?=<img\b)", result)[1:]
|
|
506
|
+
if 'web_uri="' not in seg.split(">", 1)[0][:2000]]
|
|
507
|
+
if unseen:
|
|
508
|
+
die("the article contains an <img> tag this skill could not parse, so "
|
|
509
|
+
"it can neither be uploaded to 头条 nor safely removed — and 头条 "
|
|
510
|
+
"rejects any article with an external image. Nothing was "
|
|
511
|
+
"published:\n - " + "\n - ".join(unseen)
|
|
512
|
+
+ "\nUsually an unterminated tag, or an unclosed <script>/<style> "
|
|
513
|
+
"earlier in the body hiding it from the parser. "
|
|
514
|
+
"--drop-failed-images does NOT bypass this.")
|
|
493
515
|
if failures and not drop_failed:
|
|
494
516
|
die("could not upload these images to 头条, and 头条 rejects any article "
|
|
495
517
|
"with an external image, so nothing was published:\n - "
|