publishport-opencli 1.0.30 → 1.0.32

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.
Files changed (104) hide show
  1. package/cli-manifest.json +345 -71
  2. package/clis/_shared/article/login.js +1 -1
  3. package/clis/_shared/login-wait.js +1 -0
  4. package/clis/_shared/site-auth.js +1 -1
  5. package/clis/baijiahao/article.js +2 -2
  6. package/clis/baijiahao/auth.js +18 -0
  7. package/clis/baijiahao/lists.js +1 -1
  8. package/clis/baijiahao/stats.js +1 -1
  9. package/clis/bilibili/coin.js +1 -1
  10. package/clis/bilibili/comment.js +1 -1
  11. package/clis/bilibili/comments.js +1 -1
  12. package/clis/bilibili/dm-list.js +1 -1
  13. package/clis/bilibili/dm-read.js +1 -1
  14. package/clis/bilibili/favorite-add.js +1 -1
  15. package/clis/bilibili/me.js +1 -1
  16. package/clis/bilibili/search.js +1 -1
  17. package/clis/bilibili/stats.js +1 -1
  18. package/clis/bilibili/user-videos.js +1 -1
  19. package/clis/bilibili/utils.js +7 -7
  20. package/clis/cnblogs/article.js +1 -1
  21. package/clis/csdn/stats.js +7 -13
  22. package/clis/devto/publish.js +20 -4
  23. package/clis/douyin/_shared/work-list.js +1 -0
  24. package/clis/douyin/publish.js +9 -8
  25. package/clis/douyin/stats.js +1 -1
  26. package/clis/douyin/videos.js +1 -1
  27. package/clis/jike/comments.js +2 -1
  28. package/clis/jike/like.js +1 -1
  29. package/clis/jike/notifications.js +2 -115
  30. package/clis/jike/post.js +2 -2
  31. package/clis/jike/topic.js +1 -1
  32. package/clis/jike/topics.js +3 -0
  33. package/clis/nowcoder/auth.js +1 -24
  34. package/clis/nowcoder/comment.js +1 -1
  35. package/clis/nowcoder/comments.js +1 -1
  36. package/clis/nowcoder/delete.js +1 -1
  37. package/clis/nowcoder/detail.js +1 -48
  38. package/clis/nowcoder/dm-list.js +1 -1
  39. package/clis/nowcoder/dm-read.js +1 -1
  40. package/clis/nowcoder/dm-send.js +1 -1
  41. package/clis/nowcoder/experience.js +1 -22
  42. package/clis/nowcoder/inbox.js +1 -1
  43. package/clis/nowcoder/like-status.js +1 -0
  44. package/clis/nowcoder/like.js +1 -1
  45. package/clis/nowcoder/my.js +1 -1
  46. package/clis/nowcoder/notifications.js +1 -16
  47. package/clis/nowcoder/post.js +1 -1
  48. package/clis/nowcoder/recommend.js +1 -1
  49. package/clis/nowcoder/search.js +1 -32
  50. package/clis/nowcoder/utils.js +38 -44
  51. package/clis/qiita/publish.js +2 -2
  52. package/clis/quora/__fixtures__/dom.html +24 -0
  53. package/clis/quora/answer-detail.js +1 -1
  54. package/clis/quora/question.js +25 -1
  55. package/clis/quora/search.js +1 -1
  56. package/clis/quora/shared.js +18 -17
  57. package/clis/quora/upvote.js +5 -13
  58. package/clis/reddit/comment.js +25 -25
  59. package/clis/reddit/reply.js +7 -5
  60. package/clis/reddit/search.js +1 -51
  61. package/clis/reddit/shared.js +13 -0
  62. package/clis/reddit/subreddit-info.js +10 -4
  63. package/clis/reddit/subreddit-rules.js +1 -0
  64. package/clis/reddit/subreddit.js +1 -52
  65. package/clis/reddit/upvote.js +33 -10
  66. package/clis/reddit/user-comments.js +1 -20
  67. package/clis/reddit/user-posts.js +1 -17
  68. package/clis/twitter/post.js +63 -39
  69. package/clis/wechat-channels/auth.js +34 -31
  70. package/clis/wechat-channels/publish.js +295 -130
  71. package/clis/xianyu/watch.js +43 -1
  72. package/clis/xiaohongshu/_shared/note-manager.js +37 -0
  73. package/clis/xiaohongshu/creator-notes.js +9 -43
  74. package/clis/xiaohongshu/delete-note.js +7 -19
  75. package/clis/xiaohongshu/draft-delete.js +2 -2
  76. package/clis/xiaohongshu/publish.js +34 -43
  77. package/clis/xiaohongshu/search.js +18 -3
  78. package/clis/youtube/publish.js +11 -11
  79. package/clis/zhihu/answer-comments.js +2 -11
  80. package/clis/zhihu/answer-detail.js +1 -11
  81. package/clis/zhihu/comment.js +4 -4
  82. package/clis/zhihu/like.js +17 -3
  83. package/clis/zhihu/question.js +20 -6
  84. package/clis/zhihu/read-shared.js +26 -0
  85. package/clis/zhihu/recommend.js +1 -7
  86. package/clis/zhihu/search.js +1 -11
  87. package/dist/src/browser/base-page.d.ts +41 -1
  88. package/dist/src/browser/base-page.js +29 -16
  89. package/dist/src/browser/find.d.ts +31 -4
  90. package/dist/src/browser/find.js +76 -23
  91. package/dist/src/browser/utils.d.ts +30 -0
  92. package/dist/src/browser/utils.js +7 -1
  93. package/dist/src/cli.js +22 -22
  94. package/dist/src/commanderAdapter.d.ts +14 -0
  95. package/dist/src/commanderAdapter.js +4 -3
  96. package/dist/src/doctor.d.ts +7 -0
  97. package/dist/src/doctor.js +9 -9
  98. package/dist/src/errors.d.ts +6 -0
  99. package/dist/src/errors.js +1 -1
  100. package/dist/src/execution.d.ts +1 -0
  101. package/dist/src/execution.js +2 -2
  102. package/dist/src/main.js +6 -6
  103. package/dist/src/types.d.ts +28 -0
  104. package/package.json +1 -1
@@ -1,14 +1,6 @@
1
- import{CommandExecutionError as a}from"@jackwener/opencli/errors";import{cli as u,Strategy as n}from"@jackwener/opencli/registry";import{normalizeQuoraUrl as s,openQuoraPage as i,requireExecute as l,waitForSelector as p}from"./shared.js";u({site:"quora",name:"upvote",access:"write",description:"Upvote one Quora answer through the visible logged-in UI. Idempotent when already upvoted.",domain:"www.quora.com",strategy:n.UI,browser:!0,args:[{name:"url",type:"string",required:!0,positional:!0,help:"Quora answer URL"},{name:"execute",type:"boolean",help:"Actually apply the upvote"}],columns:["status","outcome","targetType","target","message"],func:async(t,o)=>{l(o);const e=s(o.url,{allowProfile:!1,allowQuestion:!1,allowAnswer:!0});await i(t,e.url,"upvote");await p(t,"button.puppeteer_test_votable_upvote_button","answer upvote button");const r=await t.evaluate(`(() => {
2
- const button = document.querySelector('button.puppeteer_test_votable_upvote_button');
3
- if (!button) return { ok: false, message: 'Upvote button missing' };
4
- const state = ((button.getAttribute('aria-label') || '') + ' ' + (button.innerText || '')).trim();
5
- const selected = button.getAttribute('aria-pressed') === 'true' || /upvoted|remove upvote/i.test(state);
6
- if (selected) return { ok: true, already: true };
7
- button.click();
8
- return { ok: true, already: false };
9
- })()`);if(!r?.ok)throw new a(r?.message||"Failed to find the Quora upvote control.");if(r.already)return[{status:"success",outcome:"already_applied",targetType:"answer",target:e.url,message:"Answer was already upvoted."}];await t.wait(1);if(!await t.evaluate(`(() => {
10
- const button = document.querySelector('button.puppeteer_test_votable_upvote_button');
1
+ import{CommandExecutionError as l}from"@jackwener/opencli/errors";import{cli as d,Strategy as f}from"@jackwener/opencli/registry";import{evaluateQuoraDom as i,normalizeQuoraUrl as w,openQuoraPage as v,requireExecute as m,waitForSelector as b}from"./shared.js";const p="button.puppeteer_test_votable_upvote_button",h=6;function c(e){return e==null?"not readable":String(e)}d({site:"quora",name:"upvote",access:"write",description:"Upvote one Quora answer through the visible logged-in UI. Idempotent when already upvoted.",domain:"www.quora.com",strategy:f.UI,browser:!0,args:[{name:"url",type:"string",required:!0,positional:!0,help:"Quora answer URL"},{name:"execute",type:"boolean",help:"Actually apply the upvote"}],columns:["status","outcome","targetType","target","upvotesBefore","upvotesAfter","message"],func:async(e,a)=>{m(a);const s=w(a.url,{allowProfile:!1,allowQuestion:!1,allowAnswer:!0});await v(e,s.url,"upvote");await b(e,p,"answer upvote button");const t=await i(e,"upvote-state");if(!t?.found)throw new l("Failed to find the Quora upvote control.");const o={targetType:"answer",target:s.url,upvotesBefore:t.upvotes??null};if(t.pressed)return[{status:"success",outcome:"already_applied",...o,upvotesAfter:t.upvotes??null,message:"Answer was already upvoted."}];if(!await e.evaluate(`(() => {
2
+ const button = document.querySelector('#mainContent ${p}');
11
3
  if (!button) return false;
12
- const state = ((button.getAttribute('aria-label') || '') + ' ' + (button.innerText || '')).trim();
13
- return button.getAttribute('aria-pressed') === 'true' || /upvoted|remove upvote/i.test(state);
14
- })()`))throw new a("Quora did not confirm the upvote; check the answer before retrying.");return[{status:"success",outcome:"applied",targetType:"answer",target:e.url,message:"Answer upvoted."}]}});
4
+ button.click();
5
+ return true;
6
+ })()`))throw new l("Failed to find the Quora upvote control.");let r=null;for(let n=0;n<h;n++){await e.wait(1);r=await i(e,"upvote-state");if(r?.pressed)break}const u=r?.upvotes??null;if(r?.pressed)return[{status:"success",outcome:"applied",...o,upvotesAfter:u,message:"Answer upvoted."}];return[{status:"unconfirmed",outcome:"unconfirmed",...o,upvotesAfter:u,message:`The upvote was clicked but Quora did not show the pressed state (upvotes before=${c(o.upvotesBefore)}, after=${c(u)}). Do NOT run upvote again. Verify with: quora answer-detail <url> (upvoted / upvotes).`}]}});
@@ -1,18 +1,16 @@
1
- import{CommandExecutionError as s}from"@jackwener/opencli/errors";import{cli as o,Strategy as a}from"@jackwener/opencli/registry";o({site:"reddit",name:"comment",access:"write",description:"Post a comment on a Reddit post",domain:"reddit.com",strategy:a.COOKIE,browser:!0,args:[{name:"post-id",type:"string",required:!0,positional:!0,help:"Post ID (e.g. 1abc123) or fullname (t3_xxx)"},{name:"text",fromFile:!0,type:"string",required:!0,positional:!0,help:"Comment text"}],columns:["status","message","comment_id","url"],func:async(t,r)=>{if(!t)throw new s("Browser session required");await t.goto("https://www.reddit.com");const e=await t.evaluate(`(async () => {
1
+ import{ArgumentError as p,AuthRequiredError as w,CommandExecutionError as s}from"@jackwener/opencli/errors";import{cli as y,Strategy as b}from"@jackwener/opencli/registry";import{createdCommentFromThings as g,findOwnComment as k}from"./shared.js";const h=3,d="403 时停止写入并检查平台提示;断连或回执不完整时,先用 user-comments 或 read 回查,禁止直接重复评论。";function _(t){let n=String(t??"").trim();const e=n.match(/comments\/([a-z0-9]+)/i);if(e)n=e[1];const i=/^t[13]_/i.test(n)?n:`t3_${n}`;if(!/^t[13]_[a-z0-9]+$/i.test(i))throw new p("Post ID must be a Reddit post id, t3_/t1_ fullname, or reddit.com post URL.","Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.");return i.toLowerCase()}function R(t,n){return`(async () => {
2
+ let stage = 'identity';
2
3
  try {
3
- let postId = ${JSON.stringify(r["post-id"])};
4
- const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
5
- if (urlMatch) postId = urlMatch[1];
6
- const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
7
- ? postId : 't3_' + postId;
4
+ const fullname = ${JSON.stringify(t)};
5
+ const text = ${JSON.stringify(n)};
8
6
 
9
- const text = ${JSON.stringify(r.text)};
10
-
11
- // Get modhash
12
7
  const meRes = await fetch('/api/me.json', { credentials: 'include' });
13
8
  const me = await meRes.json();
9
+ const username = me?.data?.name || '';
14
10
  const modhash = me?.data?.modhash || '';
15
11
 
12
+ stage = 'submit';
13
+ const submittedAt = Math.floor(Date.now() / 1000);
16
14
  const res = await fetch('/api/comment', {
17
15
  method: 'POST',
18
16
  credentials: 'include',
@@ -29,29 +27,31 @@ import{CommandExecutionError as s}from"@jackwener/opencli/errors";import{cli as
29
27
  const doc = new DOMParser().parseFromString(raw, 'text/html');
30
28
  doc.querySelectorAll('script,style').forEach(el => el.remove());
31
29
  const detail = doc.body.textContent.replace(/\\s+/g, ' ').trim().slice(0, 500);
32
- return { ok: false, message: 'HTTP ' + res.status + ': non-JSON response: ' + detail };
30
+ return { kind: 'rejected', detail: 'HTTP ' + res.status + ': non-JSON response: ' + detail };
33
31
  }
34
32
  if (!res.ok) {
35
33
  const detail = data?.json?.errors?.map(e => e.join(': ')).join('; ') || data?.message || data?.reason || data?.error || 'Reddit rejected request';
36
- return { ok: false, message: 'HTTP ' + res.status + ': ' + String(detail).slice(0, 500) };
34
+ return { kind: 'rejected', detail: 'HTTP ' + res.status + ': ' + String(detail).slice(0, 500) };
37
35
  }
38
36
  const errors = data?.json?.errors;
39
37
  if (errors && errors.length > 0) {
40
- return { ok: false, message: errors.map(e => e.join(': ')).join('; ') };
38
+ return { kind: 'rejected', detail: errors.map(e => e.join(': ')).join('; ') };
41
39
  }
42
40
  const things = data?.json?.data?.things;
43
- const created = Array.isArray(things) ? things.find(t => t?.kind === 't1')?.data : null;
44
- const commentId = created?.id || String(created?.name || '').replace(/^t1_/, '');
45
- if (!/^[a-z0-9]+$/i.test(commentId || '')) {
46
- return { ok: false, message: 'Reddit response did not include a created comment id; publication is unconfirmed' };
47
- }
48
- let permalink = '';
49
- if (created.permalink) {
50
- const parsed = new URL(created.permalink, 'https://www.reddit.com');
51
- if (parsed.protocol === 'https:' && (parsed.hostname === 'reddit.com' || parsed.hostname.endsWith('.reddit.com'))) permalink = parsed.href;
52
- }
53
- return { ok: true, notice: 'Comment submitted on ' + fullname + '; public visibility not verified', commentId, permalink };
41
+ return {
42
+ kind: 'accepted',
43
+ username,
44
+ submittedAt,
45
+ things: Array.isArray(things) ? things.map(t => ({
46
+ kind: t?.kind,
47
+ data: { id: t?.data?.id, name: t?.data?.name, permalink: t?.data?.permalink },
48
+ })) : null,
49
+ };
54
50
  } catch (e) {
55
- return { ok: false, message: e.toString() };
51
+ return { kind: stage === 'submit' ? 'lost' : 'failed', detail: e.toString(), submittedAt: Math.floor(Date.now() / 1000) };
56
52
  }
57
- })()`);if(!e?.ok)throw new s(e?.message||"Reddit comment returned no result","403 时停止写入并检查平台提示;断连或回执不完整时,先用 user-comments 或 read 回查,禁止直接重复评论。");return[{status:"success",message:e.notice,comment_id:e.commentId,url:e.permalink}]}});
53
+ })()`}async function m(t,n,e){for(let i=0;i<e;i++){try{const r=await k(t,n);if(r)return r}catch{return null}if(i<e-1)await t.wait?.(2)}return null}async function j(t){try{return await t.evaluate(`(async () => {
54
+ const res = await fetch('/api/me.json', { credentials: 'include' });
55
+ const me = await res.json();
56
+ return me?.data?.name || '';
57
+ })()`)}catch{return""}}y({site:"reddit",name:"comment",access:"write",description:"Post a comment on a Reddit post",domain:"reddit.com",strategy:b.COOKIE,browser:!0,args:[{name:"post-id",type:"string",required:!0,positional:!0,help:"Post ID (e.g. 1abc123) or fullname (t3_xxx)"},{name:"text",fromFile:!0,type:"string",required:!0,positional:!0,help:"Comment text"}],columns:["status","message","comment_id","url"],func:async(t,n)=>{if(!t)throw new s("Browser session required");const e=_(n["post-id"]),i=String(n.text??"");await t.goto("https://www.reddit.com");const r=await t.evaluate(R(e,i));if(r?.kind==="rejected"||r?.kind==="failed")throw new s(r.detail||"Reddit comment returned no result",d);if(r?.kind==="lost"){const a=await j(t),f=a?await m(t,{username:a,parentFullname:e,text:i,submittedAt:r.submittedAt},h):null;if(!f)throw new s(`${r.detail} — the comment request on ${e} was sent but its response was lost, and read-back did not find the comment.`,d);return[c(e,f,"response lost; confirmed by read-back")]}if(r?.kind!=="accepted")throw new s("Reddit comment returned no result",d);if(!r.username)throw new w("reddit.com","Not logged in to reddit.com (no identity in /api/me.json)");const o=g(r.things);if(o?.permalink)return[{status:"success",message:`Comment submitted on ${e}; public visibility not verified`,comment_id:o.id,url:o.permalink}];const u={username:r.username,parentFullname:e,text:i,submittedAt:r.submittedAt};if(o){const a=await m(t,{...u,commentId:o.id},1);if(a)return[c(e,a,"confirmed by read-back")];return[{status:"success",message:`Comment submitted on ${e}; public visibility not verified`,comment_id:o.id,url:""}]}const l=await m(t,u,h);if(l)return[c(e,l,"confirmed by read-back")];return[{status:"submitted_unconfirmed",message:`Reddit accepted the comment on ${e} (HTTP 200, no errors) but returned no comment id, and read-back has not found it yet. Do NOT post it again. Verify later with: reddit user-comments ${r.username} --limit 10 (match link_id / body).`,comment_id:"",url:""}]}});function c(t,n,e){const i=n.removedByCategory?`; already removed (removed_by_category=${n.removedByCategory})`:"; public visibility not verified";return{status:"success",message:`Comment submitted on ${t}; ${e}${i}`,comment_id:n.id,url:n.permalink}}
@@ -1,6 +1,6 @@
1
- import{ArgumentError as o,AuthRequiredError as u,CommandExecutionError as i}from"@jackwener/opencli/errors";import{cli as l,Strategy as h}from"@jackwener/opencli/registry";const p=/^[a-z0-9]+$/i;function m(n){const t=String(n||"").trim();if(!p.test(t))throw new o("Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");return t.toLowerCase()}export function normalizeRedditCommentFullname(n){const t=String(n||"").trim();if(!t)throw new o("Comment ID is required.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");const s=t.match(/^t1_([a-z0-9]+)$/i);if(s)return`t1_${m(s[1])}`;if(/^https?:\/\//i.test(t)){let r;try{r=new URL(t)}catch{throw new o(`Invalid Reddit comment URL: ${t}`)}const e=r.hostname.toLowerCase();if(r.protocol!=="https:"||e!=="reddit.com"&&!e.endsWith(".reddit.com"))throw new o("Comment URL must be an https reddit.com URL.","Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/");const a=r.pathname.split("/").filter(Boolean),c=a.indexOf("comments"),d=c+3;if(c<0||a.length<=d)throw new o("Comment URL must include the target comment id.","Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/");if(a.length!==d+1)throw new o("Comment URL must end at the target comment id.","Remove extra path segments after the comment id.");return`t1_${m(a[d])}`}if(t.includes("/")||t.startsWith("t3_"))throw new o("Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");return`t1_${m(t)}`}export function requireReplyText(n){const t=String(n||"");if(!t.trim())throw new o("Reply text is required.","Pass non-empty text to post as the Reddit reply.");return t}l({site:"reddit",name:"reply",access:"write",description:"Reply to a Reddit comment",domain:"reddit.com",strategy:h.COOKIE,browser:!0,args:[{name:"comment-id",type:"string",required:!0,positional:!0,help:"Comment ID (e.g. okf3s7u) or fullname (t1_xxx)"},{name:"text",fromFile:!0,type:"string",required:!0,positional:!0,help:"Reply text"}],columns:["status","message"],func:async(n,t)=>{const s=normalizeRedditCommentFullname(t["comment-id"]),r=requireReplyText(t.text);await n.goto("https://www.reddit.com");const e=await n.evaluate(`(async () => {
1
+ import{ArgumentError as o,AuthRequiredError as l,CommandExecutionError as i}from"@jackwener/opencli/errors";import{cli as u,Strategy as h}from"@jackwener/opencli/registry";const p=/^[a-z0-9]+$/i;function m(n){const t=String(n||"").trim();if(!p.test(t))throw new o("Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");return t.toLowerCase()}export function normalizeRedditCommentFullname(n){const t=String(n||"").trim();if(!t)throw new o("Comment ID is required.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");const a=t.match(/^t1_([a-z0-9]+)$/i);if(a)return`t1_${m(a[1])}`;if(/^https?:\/\//i.test(t)){let r;try{r=new URL(t)}catch{throw new o(`Invalid Reddit comment URL: ${t}`)}const e=r.hostname.toLowerCase();if(r.protocol!=="https:"||e!=="reddit.com"&&!e.endsWith(".reddit.com"))throw new o("Comment URL must be an https reddit.com URL.","Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/");const s=r.pathname.split("/").filter(Boolean),c=s.indexOf("comments"),d=c+3;if(c<0||s.length<=d)throw new o("Comment URL must include the target comment id.","Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/");if(s.length!==d+1)throw new o("Comment URL must end at the target comment id.","Remove extra path segments after the comment id.");return`t1_${m(s[d])}`}if(t.includes("/")||t.startsWith("t3_"))throw new o("Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.","Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.");return`t1_${m(t)}`}export function requireReplyText(n){const t=String(n||"");if(!t.trim())throw new o("Reply text is required.","Pass non-empty text to post as the Reddit reply.");return t}u({site:"reddit",name:"reply",access:"write",description:"Reply to a Reddit comment",domain:"reddit.com",strategy:h.COOKIE,browser:!0,args:[{name:"comment-id",type:"string",required:!0,positional:!0,help:"Comment ID (e.g. okf3s7u) or fullname (t1_xxx)"},{name:"text",fromFile:!0,type:"string",required:!0,positional:!0,help:"Reply text"}],columns:["status","message"],func:async(n,t)=>{const a=normalizeRedditCommentFullname(t["comment-id"]),r=requireReplyText(t.text);await n.goto("https://www.reddit.com");const e=await n.evaluate(`(async () => {
2
2
  try {
3
- const fullname = ${JSON.stringify(s)};
3
+ const fullname = ${JSON.stringify(a)};
4
4
  const text = ${JSON.stringify(r)};
5
5
 
6
6
  // Probe identity + modhash. /api/me.json returns data.name only when
@@ -43,9 +43,11 @@ import{ArgumentError as o,AuthRequiredError as u,CommandExecutionError as i}from
43
43
  }
44
44
  const things = data?.json?.data?.things;
45
45
  const created = Array.isArray(things)
46
- ? things.find((thing) => thing?.kind === 't1' || String(thing?.data?.name || '').startsWith('t1_'))
46
+ ? things.find((thing) => thing?.kind === 't1' || String(thing?.data?.name || thing?.data?.id || '').startsWith('t1_'))
47
47
  : null;
48
- const createdName = created?.data?.name || (created?.data?.id ? 't1_' + created.data.id : '');
48
+ // www 站表单响应里 data.id 本身就是 fullname(t1_xxx),没有 data.name。
49
+ const createdId = String(created?.data?.name || created?.data?.id || '');
50
+ const createdName = createdId ? (createdId.startsWith('t1_') ? createdId : 't1_' + createdId) : '';
49
51
  if (!createdName) {
50
52
  return { kind: 'postcondition', detail: 'Reddit comment response did not include a created reply id' };
51
53
  }
@@ -53,4 +55,4 @@ import{ArgumentError as o,AuthRequiredError as u,CommandExecutionError as i}from
53
55
  } catch (e) {
54
56
  return { kind: 'exception', detail: String(e && e.message || e) };
55
57
  }
56
- })()`);if(e?.kind==="auth")throw new u("reddit.com",e.detail);if(e?.kind==="http")throw new i(`HTTP ${e.httpStatus} from ${e.where}`);if(e?.kind==="reddit-error")throw new i(`Reddit rejected reply: ${e.detail}`);if(e?.kind==="postcondition")throw new i(e.detail);if(e?.kind==="exception")throw new i(`Reply failed: ${e.detail}`);if(e?.kind!=="ok")throw new i(`Unexpected result from reddit reply: ${JSON.stringify(e)}`);return[{status:"success",message:e.detail}]}});
58
+ })()`);if(e?.kind==="auth")throw new l("reddit.com",e.detail);if(e?.kind==="http")throw new i(`HTTP ${e.httpStatus} from ${e.where}`);if(e?.kind==="reddit-error")throw new i(`Reddit rejected reply: ${e.detail}`);if(e?.kind==="postcondition")throw new i(e.detail);if(e?.kind==="exception")throw new i(`Reply failed: ${e.detail}`);if(e?.kind!=="ok")throw new i(`Unexpected result from reddit reply: ${JSON.stringify(e)}`);return[{status:"success",message:e.detail}]}});
@@ -1,51 +1 @@
1
- import{cli as e,Strategy as t}from"@jackwener/opencli/registry";e({site:"reddit",name:"search",access:"read",description:"Search Reddit Posts",domain:"reddit.com",strategy:t.COOKIE,browser:!0,args:[{name:"query",type:"string",required:!0,positional:!0,help:"Reddit search query"},{name:"subreddit",type:"string",default:"",help:"Search within a specific subreddit"},{name:"sort",type:"string",default:"relevance",help:"Sort order: relevance, hot, top, new, comments"},{name:"time",type:"string",default:"all",help:"Time filter: hour, day, week, month, year, all"},{name:"limit",type:"int",default:15}],columns:["id","title","subreddit","author","score","comments","url","created_utc","selftext","post_hint","url_overridden_by_dest","preview_image_url","gallery_urls"],pipeline:[{evaluate:`(async () => {
2
- function decodeHtml(s) {
3
- if (typeof s !== 'string' || !s) return '';
4
- return s
5
- .replace(/&amp;/g, '&')
6
- .replace(/&lt;/g, '<')
7
- .replace(/&gt;/g, '>')
8
- .replace(/&quot;/g, '"')
9
- .replace(/&#x27;/gi, "'")
10
- .replace(/&#39;/g, "'");
11
- }
12
- function extractRedditMedia(d) {
13
- const post_hint = d?.post_hint || '';
14
- const url_overridden_by_dest = d?.url_overridden_by_dest || '';
15
- const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
16
- const gallery_urls = [];
17
- const items = d?.gallery_data?.items;
18
- const meta = d?.media_metadata;
19
- if (Array.isArray(items) && meta) {
20
- for (const it of items) {
21
- const m = it && meta[it.media_id];
22
- const u = m?.s?.u;
23
- if (u) gallery_urls.push(decodeHtml(u));
24
- }
25
- }
26
- return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
27
- }
28
- const q = encodeURIComponent(\${{ args.query | json }});
29
- const sub = \${{ args.subreddit | json }};
30
- const sort = \${{ args.sort | json }};
31
- const time = \${{ args.time | json }};
32
- const limit = \${{ args.limit }};
33
- const basePath = sub ? '/r/' + sub + '/search.json' : '/search.json';
34
- const params = 'q=' + q + '&sort=' + sort + '&t=' + time + '&limit=' + limit
35
- + '&restrict_sr=' + (sub ? 'on' : 'off') + '&raw_json=1';
36
- const res = await fetch(basePath + '?' + params, { credentials: 'include' });
37
- const d = await res.json();
38
- return (d?.data?.children || []).map(c => ({
39
- id: c.data.id,
40
- title: c.data.title,
41
- subreddit: c.data.subreddit_name_prefixed,
42
- author: c.data.author,
43
- score: c.data.score,
44
- comments: c.data.num_comments,
45
- url: 'https://www.reddit.com' + c.data.permalink,
46
- created_utc: c.data.created_utc,
47
- selftext: c.data.selftext || '',
48
- ...extractRedditMedia(c.data),
49
- }));
50
- })()
51
- `},{map:{id:"${{ item.id }}",title:"${{ item.title }}",subreddit:"${{ item.subreddit }}",author:"${{ item.author }}",score:"${{ item.score }}",comments:"${{ item.comments }}",url:"${{ item.url }}",created_utc:"${{ item.created_utc }}",selftext:"${{ item.selftext }}",post_hint:"${{ item.post_hint }}",url_overridden_by_dest:"${{ item.url_overridden_by_dest }}",preview_image_url:"${{ item.preview_image_url }}",gallery_urls:"${{ item.gallery_urls }}"}},{limit:"${{ args.limit }}"}]});
1
+ import{ArgumentError as c,CommandExecutionError as h}from"@jackwener/opencli/errors";import{cli as p,Strategy as f}from"@jackwener/opencli/registry";import{clipText as y,extractRedditMedia as b,listingChildren as _,normalizeSubredditName as w,parseMaxLength as x,redditGetJson as g,requirePositiveInt as q}from"./shared.js";p({site:"reddit",name:"search",access:"read",description:"Search Reddit Posts",domain:"reddit.com",strategy:f.COOKIE,browser:!0,args:[{name:"query",type:"string",required:!0,positional:!0,help:"Reddit search query"},{name:"subreddit",type:"string",default:"",help:"Search within a specific subreddit"},{name:"sort",type:"string",default:"relevance",help:"Sort order: relevance, hot, top, new, comments"},{name:"time",type:"string",default:"all",help:"Time filter: hour, day, week, month, year, all"},{name:"limit",type:"int",default:15},{name:"max-length",type:"int",help:"Max characters of selftext per result (0 = omit the body; default: full body)"}],columns:["id","title","subreddit","author","score","comments","url","created_utc","selftext","post_hint","url_overridden_by_dest","preview_image_url","gallery_urls"],func:async(i,t)=>{if(!i)throw new h("Browser session required");const a=String(t.query??"").trim();if(!a)throw new c("Search query is required");const r=String(t.subreddit??"").trim()?w(t.subreddit):"",n=String(t.sort??"relevance"),o=String(t.time??"all"),d=q(t.limit,"--limit",15),s=x(t["max-length"]);await i.goto("https://www.reddit.com");const l=r?`/r/${r}/search.json`:"/search.json",m=`q=${encodeURIComponent(a)}&sort=${encodeURIComponent(n)}&t=${encodeURIComponent(o)}&limit=${d}&restrict_sr=${r?"on":"off"}&raw_json=1`,u=await g(i,`${l}?${m}`,{label:"reddit search",subreddit:r||void 0});return _(u,"reddit search").filter((e)=>e?.kind==="t3").slice(0,d).map((e)=>({id:e.data.id,title:e.data.title,subreddit:e.data.subreddit_name_prefixed,author:e.data.author,score:e.data.score,comments:e.data.num_comments,url:"https://www.reddit.com"+e.data.permalink,created_utc:e.data.created_utc,selftext:y(e.data.selftext,s),...b(e.data)}))}});
@@ -0,0 +1,13 @@
1
+ import{ArgumentError as u,AuthRequiredError as p,CliError as l,CommandExecutionError as c,EXIT_CODES as m}from"@jackwener/opencli/errors";export const REDDIT_ORIGIN="https://www.reddit.com";const h=/^[A-Za-z][A-Za-z0-9_]{2,20}$/,w=/^[A-Za-z0-9_-]{3,20}$/;export function normalizeSubredditName(t){let e=String(t??"").trim();if(!e)throw new u("Subreddit name is required.","Pass a subreddit name like `python` (or `r/python`).");const r=e.match(/^https?:\/\/(?:[a-z0-9-]+\.)?reddit\.com\/r\/([^/?#]+)/i);if(r)e=r[1];else if(e.startsWith("/r/"))e=e.slice(3);else if(e.startsWith("r/"))e=e.slice(2);e=e.replace(/\/+$/,"");if(!h.test(e))throw new u(`Invalid subreddit name: ${String(t).trim()}`,"Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.");return e}export function normalizeRedditUsername(t){let e=String(t??"").trim();if(!e)throw new u("Reddit username is required.","Pass a username like `spez` (or `u/spez`).");const r=e.match(/^https?:\/\/(?:[a-z0-9-]+\.)?reddit\.com\/(?:u|user)\/([^/?#]+)/i);if(r)e=r[1];else e=e.replace(/^\/?(?:u|user)\//i,"");e=e.replace(/\/+$/,"");if(!w.test(e))throw new u(`Invalid Reddit username: ${String(t).trim()}`,"Pass a bare username, `u/<name>`, or a https://www.reddit.com/user/<name> URL.");return e}export function requirePositiveInt(t,e,r){const i=t==null||t===""?r:Number(t);if(!Number.isInteger(i)||i<1)throw new u(`${e} must be a positive integer`);return i}export function parseMaxLength(t){if(t==null||t==="")return null;const e=Number(t);if(!Number.isInteger(e)||e<0)throw new u("--max-length must be a non-negative integer (0 omits the body)");return e}export function clipText(t,e){const r=typeof t==="string"?t:"";if(e==null)return r;if(e===0)return"";return r.length>e?`${r.slice(0,e)}…`:r}export function decodeRedditHtml(t){if(typeof t!=="string"||!t)return"";return t.replace(/&amp;/g,"&").replace(/&lt;/g,"<").replace(/&gt;/g,">").replace(/&quot;/g,'"').replace(/&#x27;/gi,"'").replace(/&#39;/g,"'")}export function extractRedditMedia(t){const e=t?.post_hint||"",r=t?.url_overridden_by_dest||"",i=decodeRedditHtml(t?.preview?.images?.[0]?.source?.url||""),s=[],a=t?.gallery_data?.items,n=t?.media_metadata;if(Array.isArray(a)&&n)for(const o of a){const d=o&&n[o.media_id]?.s?.u;if(d)s.push(decodeRedditHtml(d))}return{post_hint:e,url_overridden_by_dest:r,preview_image_url:i,gallery_urls:s}}export function redditGetJs(t){return`(async () => {
2
+ try {
3
+ const res = await fetch(${JSON.stringify(REDDIT_ORIGIN+t)}, { credentials: 'include' });
4
+ let finalPath = '';
5
+ try { finalPath = new URL(res.url).pathname; } catch {}
6
+ const raw = await res.text();
7
+ let payload = null;
8
+ try { payload = JSON.parse(raw); } catch {}
9
+ return { httpStatus: res.status, finalPath, payload, parsed: payload !== null };
10
+ } catch (e) {
11
+ return { thrown: String(e && e.message || e) };
12
+ }
13
+ })()`}export function subredditNotFoundError(t){return new l("NOT_FOUND",`Subreddit r/${t} does not exist.`,"Reddit only offered similarly named communities, which are different subreddits. Check the exact name with `reddit search` before using it.",m.EMPTY_RESULT)}export function isSubredditSearchRedirect(t){return/^\/subreddits\/search(?:\.json)?\/?$/.test(String(t?.finalPath||""))}export async function redditGetJson(t,e,r){const i=await t.evaluate(redditGetJs(e)),s=e.split("?")[0];if(!i||i.thrown)throw new c(`${r.label} request failed: ${i?.thrown||"no response from page"}`);if(r.subreddit&&isSubredditSearchRedirect(i))throw subredditNotFoundError(r.subreddit);const{payload:a,httpStatus:n}=i,o=a&&typeof a==="object"&&!Array.isArray(a)?[a.reason,a.message].filter(Boolean).join(" / "):"";if(n===404||a?.error===404){if(a?.reason==="banned")throw new l("NOT_FOUND",`Subreddit r/${r.subreddit} is banned.`,void 0,m.EMPTY_RESULT);const d=r.subreddit?`Subreddit r/${r.subreddit}`:r.username?`Reddit user u/${r.username}`:s;throw new l("NOT_FOUND",`${d} was not found (HTTP 404).`,void 0,m.EMPTY_RESULT)}if(n===401)throw new p("reddit.com",`Reddit returned HTTP 401 for ${s}`);if(n===403||a?.error===403){const d=r.subreddit?`r/${r.subreddit}`:r.username?`u/${r.username}`:s;throw new c(`Reddit denied access to ${d} (HTTP 403${o?`: ${o}`:""}).`,"Private, quarantined or suspended targets are not readable from this account.")}if(n===429)throw new c(`Reddit rate-limited ${s} (HTTP 429).`,"Wait a few minutes before reading again.");if(n<200||n>=300)throw new c(`HTTP ${n} from ${s}${o?`: ${o}`:""}`);if(!i.parsed)throw new c(`Reddit returned a non-JSON response for ${s} (HTTP ${n}).`);return a}export function listingChildren(t,e){const r=t?.data?.children;if(t?.kind!=="Listing"||!Array.isArray(r))throw new c(`${e}: Reddit returned an unexpected payload (not a Listing).`);return r}export function createdCommentFromThings(t){if(!Array.isArray(t))return null;for(const e of t){const r=e?.data;if(!r)continue;const i=[r.name,r.id].map((n)=>String(n??"")),s=i.find((n)=>/^t1_[a-z0-9]+$/i.test(n)),a=s?s.slice(3):e.kind==="t1"&&/^[a-z0-9]+$/i.test(i[1])?i[1]:"";if(!a)continue;return{id:a.toLowerCase(),permalink:safeRedditPermalink(r.permalink)}}return null}export function safeRedditPermalink(t){if(!t||typeof t!=="string")return"";try{const e=new URL(t,REDDIT_ORIGIN);if(e.protocol==="https:"&&(e.hostname==="reddit.com"||e.hostname.endsWith(".reddit.com")))return e.href}catch{}return""}const f=(t)=>String(t??"").replace(/\s+/g," ").trim();export async function findOwnComment(t,e){const r=await redditGetJson(t,`/user/${encodeURIComponent(e.username)}/comments.json?limit=25&raw_json=1`,{label:"reddit comment read-back",username:e.username}),i=e.windowSeconds??600,s=f(e.text),a=e.parentFullname.startsWith("t3_"),n=listingChildren(r,"reddit comment read-back").map((o)=>o?.data).find((o)=>{if(!o)return!1;if(e.commentId)return String(o.id).toLowerCase()===e.commentId;if(!(a?o.link_id===e.parentFullname&&o.parent_id===e.parentFullname:o.parent_id===e.parentFullname))return!1;if(!(Number(o.created_utc)>=e.submittedAt-i))return!1;return f(o.body)===s});if(!n)return null;return{id:String(n.id).toLowerCase(),permalink:safeRedditPermalink(n.permalink),removedByCategory:n.removed_by_category??null,createdUtc:typeof n.created_utc==="number"?n.created_utc:null}}
@@ -1,13 +1,19 @@
1
- import{ArgumentError as d,CommandExecutionError as n,EmptyResultError as c}from"@jackwener/opencli/errors";import{cli as f,Strategy as b}from"@jackwener/opencli/registry";const m=/^[A-Za-z][A-Za-z0-9_]{2,20}$/;export function parseSubredditName(i){let r=String(i||"").trim();if(!r)throw new d("Subreddit name is required.","Pass a subreddit name like `python` (or `r/python`).");if(r.startsWith("/r/"))r=r.slice(3);else if(r.startsWith("r/"))r=r.slice(2);if(!m.test(r))throw new d("Invalid subreddit name.","Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.");return r}f({site:"reddit",name:"subreddit-info",access:"read",description:"Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)",domain:"reddit.com",strategy:b.COOKIE,browser:!0,args:[{name:"name",type:"string",required:!0,positional:!0,help:"Subreddit name (no `r/` prefix needed)"}],columns:["field","value"],func:async(i,r)=>{const o=parseSubredditName(r.name);await i.goto("https://www.reddit.com");const t=await i.evaluate(`(async () => {
1
+ import{ArgumentError as o,CommandExecutionError as n,EmptyResultError as c}from"@jackwener/opencli/errors";import{cli as f,Strategy as m}from"@jackwener/opencli/registry";import{subredditNotFoundError as b}from"./shared.js";const p=/^[A-Za-z][A-Za-z0-9_]{2,20}$/;export function parseSubredditName(i){let r=String(i||"").trim();if(!r)throw new o("Subreddit name is required.","Pass a subreddit name like `python` (or `r/python`).");if(r.startsWith("/r/"))r=r.slice(3);else if(r.startsWith("r/"))r=r.slice(2);if(!p.test(r))throw new o("Invalid subreddit name.","Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.");return r}f({site:"reddit",name:"subreddit-info",access:"read",description:"Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)",domain:"reddit.com",strategy:m.COOKIE,browser:!0,args:[{name:"name",type:"string",required:!0,positional:!0,help:"Subreddit name (no `r/` prefix needed)"}],columns:["field","value"],func:async(i,r)=>{const s=parseSubredditName(r.name);await i.goto("https://www.reddit.com");const t=await i.evaluate(`(async () => {
2
2
  try {
3
- const sub = ${JSON.stringify(o)};
4
- const res = await fetch('/r/' + encodeURIComponent(sub) + '/about.json?raw_json=1', { credentials: 'include' });
3
+ const sub = ${JSON.stringify(s)};
4
+ const res = await fetch('https://www.reddit.com/r/' + encodeURIComponent(sub) + '/about.json?raw_json=1', { credentials: 'include' });
5
5
  if (res.status === 401 || res.status === 403 || res.status === 404) {
6
6
  return { kind: 'missing', detail: 'Subreddit r/' + sub + ' was not found or is not accessible (HTTP ' + res.status + ').' };
7
7
  }
8
8
  if (!res.ok) {
9
9
  return { kind: 'http', httpStatus: res.status, where: '/r/' + sub + '/about.json' };
10
10
  }
11
+ // 不存在的版块:Reddit 302 到 /subreddits/search.json 并回一串相近版名(kind=t5)。
12
+ let finalPath = '';
13
+ try { finalPath = new URL(res.url).pathname; } catch {}
14
+ if (/^\\/subreddits\\/search/.test(finalPath)) {
15
+ return { kind: 'not-found' };
16
+ }
11
17
  const j = await res.json();
12
18
  // Reddit may return an envelope-style 200 with {"kind":"Listing"} or
13
19
  // an error body for quarantined / private subs. Identify "subreddit
@@ -26,4 +32,4 @@ import{ArgumentError as d,CommandExecutionError as n,EmptyResultError as c}from"
26
32
  } catch (e) {
27
33
  return { kind: 'exception', detail: String(e && e.message || e) };
28
34
  }
29
- })()`);if(t?.kind==="missing")throw new c(t.detail);if(t?.kind==="http")throw new n(`HTTP ${t.httpStatus} from ${t.where}`);if(t?.kind==="malformed")throw new n(t.detail);if(t?.kind==="exception")throw new n(`subreddit-info failed: ${t.detail}`);if(t?.kind!=="ok")throw new n(`Unexpected result from reddit subreddit-info: ${JSON.stringify(t)}`);const e=t.info,u=e.created_utc?new Date(e.created_utc*1000).toISOString().split("T")[0]:null,s=typeof e.subscribers==="number"?e.subscribers:null,a=typeof e.active_user_count==="number"?e.active_user_count:typeof e.accounts_active==="number"?e.accounts_active:null,l=typeof e.public_description==="string"?e.public_description.trim():"";return[{field:"Name",value:e.display_name_prefixed||"r/"+e.display_name},{field:"Title",value:typeof e.title==="string"?e.title:null},{field:"Subscribers",value:s!=null?String(s):null},{field:"Active Now",value:a!=null?String(a):null},{field:"NSFW",value:e.over18?"Yes":"No"},{field:"Type",value:typeof e.subreddit_type==="string"?e.subreddit_type:null},{field:"Description",value:l||null},{field:"Created",value:u},{field:"URL",value:e.url?"https://www.reddit.com"+e.url:null}]}});
35
+ })()`);if(t?.kind==="not-found")throw b(s);if(t?.kind==="missing")throw new c(t.detail);if(t?.kind==="http")throw new n(`HTTP ${t.httpStatus} from ${t.where}`);if(t?.kind==="malformed")throw new n(t.detail);if(t?.kind==="exception")throw new n(`subreddit-info failed: ${t.detail}`);if(t?.kind!=="ok")throw new n(`Unexpected result from reddit subreddit-info: ${JSON.stringify(t)}`);const e=t.info,u=e.created_utc?new Date(e.created_utc*1000).toISOString().split("T")[0]:null,a=typeof e.subscribers==="number"?e.subscribers:null,d=typeof e.active_user_count==="number"?e.active_user_count:typeof e.accounts_active==="number"?e.accounts_active:null,l=typeof e.public_description==="string"?e.public_description.trim():"";return[{field:"Name",value:e.display_name_prefixed||"r/"+e.display_name},{field:"Title",value:typeof e.title==="string"?e.title:null},{field:"Subscribers",value:a!=null?String(a):null},{field:"Active Now",value:d!=null?String(d):null},{field:"NSFW",value:e.over18?"Yes":"No"},{field:"Type",value:typeof e.subreddit_type==="string"?e.subreddit_type:null},{field:"Description",value:l||null},{field:"Created",value:u},{field:"URL",value:e.url?"https://www.reddit.com"+e.url:null}]}});
@@ -0,0 +1 @@
1
+ import{CommandExecutionError as d,EmptyResultError as a}from"@jackwener/opencli/errors";import{cli as u,Strategy as l}from"@jackwener/opencli/registry";import{normalizeSubredditName as c,redditGetJson as m}from"./shared.js";u({site:"reddit",name:"subreddit-rules",access:"read",description:"List the posting rules of a subreddit (read these before posting or commenting there)",domain:"reddit.com",strategy:l.COOKIE,browser:!0,args:[{name:"name",type:"string",required:!0,positional:!0,help:"Subreddit name (no `r/` prefix needed)"}],columns:["rank","short_name","kind","description","violation_reason","created_utc"],func:async(t,n)=>{if(!t)throw new d("Browser session required");const r=c(n.name);await t.goto("https://www.reddit.com");const o=await m(t,`/r/${r}/about/rules.json?raw_json=1`,{label:"reddit subreddit-rules",subreddit:r});if(!Array.isArray(o?.rules))throw new d(`reddit subreddit-rules: Reddit returned an unexpected payload for r/${r} (no top-level rules array).`);const s=o.rules.slice().sort((e,i)=>(e?.priority??0)-(i?.priority??0)).map((e,i)=>({rank:i+1,short_name:e.short_name??"",kind:e.kind??"",description:e.description??"",violation_reason:e.violation_reason??"",created_utc:e.created_utc??null}));if(!s.length)throw new a("reddit subreddit-rules",`r/${r} has not published subreddit-specific rules; Reddit's site-wide rules still apply.`);return s}});
@@ -1,52 +1 @@
1
- import{cli as t,Strategy as e}from"@jackwener/opencli/registry";t({site:"reddit",name:"subreddit",access:"read",description:"Get posts from a specific Subreddit",domain:"reddit.com",strategy:e.COOKIE,browser:!0,args:[{name:"name",type:"string",required:!0,positional:!0,help:"Subreddit name (no `r/` prefix; e.g. `python`)"},{name:"sort",type:"string",default:"hot",help:"Sorting method: hot, new, top, rising, controversial"},{name:"time",type:"string",default:"all",help:"Time filter for top/controversial: hour, day, week, month, year, all"},{name:"limit",type:"int",default:15}],columns:["id","title","subreddit","author","upvotes","comments","url","created_utc","selftext","post_hint","url_overridden_by_dest","preview_image_url","gallery_urls"],pipeline:[{evaluate:`(async () => {
2
- function decodeHtml(s) {
3
- if (typeof s !== 'string' || !s) return '';
4
- return s
5
- .replace(/&amp;/g, '&')
6
- .replace(/&lt;/g, '<')
7
- .replace(/&gt;/g, '>')
8
- .replace(/&quot;/g, '"')
9
- .replace(/&#x27;/gi, "'")
10
- .replace(/&#39;/g, "'");
11
- }
12
- function extractRedditMedia(d) {
13
- const post_hint = d?.post_hint || '';
14
- const url_overridden_by_dest = d?.url_overridden_by_dest || '';
15
- const preview_image_url = decodeHtml(d?.preview?.images?.[0]?.source?.url || '');
16
- const gallery_urls = [];
17
- const items = d?.gallery_data?.items;
18
- const meta = d?.media_metadata;
19
- if (Array.isArray(items) && meta) {
20
- for (const it of items) {
21
- const m = it && meta[it.media_id];
22
- const u = m?.s?.u;
23
- if (u) gallery_urls.push(decodeHtml(u));
24
- }
25
- }
26
- return { post_hint, url_overridden_by_dest, preview_image_url, gallery_urls };
27
- }
28
- let sub = \${{ args.name | json }};
29
- if (sub.startsWith('r/')) sub = sub.slice(2);
30
- const sort = \${{ args.sort | json }};
31
- const time = \${{ args.time | json }};
32
- const limit = \${{ args.limit }};
33
- let url = '/r/' + sub + '/' + sort + '.json?limit=' + limit + '&raw_json=1';
34
- if ((sort === 'top' || sort === 'controversial') && time) {
35
- url += '&t=' + time;
36
- }
37
- const res = await fetch(url, { credentials: 'include' });
38
- const j = await res.json();
39
- return (j?.data?.children || []).map(c => ({
40
- id: c.data.id,
41
- title: c.data.title,
42
- subreddit: c.data.subreddit_name_prefixed,
43
- author: c.data.author,
44
- upvotes: c.data.score,
45
- comments: c.data.num_comments,
46
- url: 'https://www.reddit.com' + c.data.permalink,
47
- created_utc: c.data.created_utc,
48
- selftext: c.data.selftext || '',
49
- ...extractRedditMedia(c.data),
50
- }));
51
- })()
52
- `},{map:{id:"${{ item.id }}",title:"${{ item.title }}",subreddit:"${{ item.subreddit }}",author:"${{ item.author }}",upvotes:"${{ item.upvotes }}",comments:"${{ item.comments }}",url:"${{ item.url }}",created_utc:"${{ item.created_utc }}",selftext:"${{ item.selftext }}",post_hint:"${{ item.post_hint }}",url_overridden_by_dest:"${{ item.url_overridden_by_dest }}",preview_image_url:"${{ item.preview_image_url }}",gallery_urls:"${{ item.gallery_urls }}"}},{limit:"${{ args.limit }}"}]});
1
+ import{ArgumentError as u,CommandExecutionError as p}from"@jackwener/opencli/errors";import{cli as c,Strategy as h}from"@jackwener/opencli/registry";import{clipText as f,extractRedditMedia as b,listingChildren as y,normalizeSubredditName as g,parseMaxLength as w,redditGetJson as x,requirePositiveInt as _}from"./shared.js";const s=["hot","new","top","rising","controversial"];c({site:"reddit",name:"subreddit",access:"read",description:"Get posts from a specific Subreddit",domain:"reddit.com",strategy:h.COOKIE,browser:!0,args:[{name:"name",type:"string",required:!0,positional:!0,help:"Subreddit name (no `r/` prefix; e.g. `python`)"},{name:"sort",type:"string",default:"hot",help:"Sorting method: hot, new, top, rising, controversial"},{name:"time",type:"string",default:"all",help:"Time filter for top/controversial: hour, day, week, month, year, all"},{name:"limit",type:"int",default:15},{name:"max-length",type:"int",help:"Max characters of selftext per post (0 = omit the body; default: full body)"}],columns:["id","title","subreddit","author","upvotes","comments","url","created_utc","selftext","post_hint","url_overridden_by_dest","preview_image_url","gallery_urls"],func:async(i,e)=>{if(!i)throw new p("Browser session required");const o=g(e.name),r=String(e.sort??"hot");if(!s.includes(r))throw new u(`--sort must be one of: ${s.join(", ")}`);const a=String(e.time??"all"),d=_(e.limit,"--limit",15),l=w(e["max-length"]);await i.goto("https://www.reddit.com");let n=`/r/${o}/${r}.json?limit=${d}&raw_json=1`;if((r==="top"||r==="controversial")&&a)n+=`&t=${encodeURIComponent(a)}`;const m=await x(i,n,{label:"reddit subreddit",subreddit:o});return y(m,"reddit subreddit").filter((t)=>t?.kind==="t3").slice(0,d).map((t)=>({id:t.data.id,title:t.data.title,subreddit:t.data.subreddit_name_prefixed,author:t.data.author,upvotes:t.data.score,comments:t.data.num_comments,url:"https://www.reddit.com"+t.data.permalink,created_utc:t.data.created_utc,selftext:f(t.data.selftext,l),...b(t.data)}))}});
@@ -1,6 +1,6 @@
1
- import{ArgumentError as a,CommandExecutionError as s}from"@jackwener/opencli/errors";import{cli as n,Strategy as i}from"@jackwener/opencli/registry";n({site:"reddit",name:"upvote",access:"write",description:"Upvote or downvote a Reddit post",domain:"reddit.com",strategy:i.COOKIE,browser:!0,args:[{name:"post-id",type:"string",required:!0,positional:!0,help:"Post ID (e.g. 1abc123) or fullname (t3_xxx)"},{name:"direction",type:"string",default:"up",help:"Vote direction: up, down, none"}],columns:["status","message"],func:async(e,o)=>{const r=o.direction??"up";if(!["up","down","none"].includes(r))throw new a("--direction must be up, down, or none");if(!e)throw new s("Browser session required");await e.goto("https://www.reddit.com");const t=await e.evaluate(`(async () => {
1
+ import{ArgumentError as i,CommandExecutionError as r}from"@jackwener/opencli/errors";import{cli as d,Strategy as c}from"@jackwener/opencli/registry";d({site:"reddit",name:"upvote",access:"write",description:"Upvote or downvote a Reddit post",domain:"reddit.com",strategy:c.COOKIE,browser:!0,args:[{name:"post-id",type:"string",required:!0,positional:!0,help:"Post ID (e.g. 1abc123) or fullname (t3_xxx)"},{name:"direction",type:"string",default:"up",help:"Vote direction: up, down, none"}],columns:["status","message","target","direction","vote_state","score"],func:async(o,s)=>{const t=s.direction??"up";if(!["up","down","none"].includes(t))throw new i("--direction must be up, down, or none");if(!o)throw new r("Browser session required");await o.goto("https://www.reddit.com");const e=await o.evaluate(`(async () => {
2
2
  try {
3
- let postId = ${JSON.stringify(o["post-id"])};
3
+ let postId = ${JSON.stringify(s["post-id"])};
4
4
  // Extract ID from URL if needed
5
5
  const urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
6
6
  if (urlMatch) postId = urlMatch[1];
@@ -8,7 +8,7 @@ import{ArgumentError as a,CommandExecutionError as s}from"@jackwener/opencli/err
8
8
  const fullname = postId.startsWith('t3_') || postId.startsWith('t1_')
9
9
  ? postId : 't3_' + postId;
10
10
 
11
- const dir = ${JSON.stringify(r)};
11
+ const dir = ${JSON.stringify(t)};
12
12
  const direction = dir === 'down' ? -1 : dir === 'none' ? 0 : 1;
13
13
 
14
14
  // Get modhash from Reddit config
@@ -24,14 +24,23 @@ import{ArgumentError as a,CommandExecutionError as s}from"@jackwener/opencli/err
24
24
  modhash = me?.data?.modhash || '';
25
25
  }
26
26
 
27
- const res = await fetch('/api/vote', {
27
+ const body = 'id=' + encodeURIComponent(fullname)
28
+ + '&dir=' + direction
29
+ + (modhash ? '&uh=' + encodeURIComponent(modhash) : '');
30
+ const post = () => fetch('/api/vote', {
28
31
  method: 'POST',
29
32
  credentials: 'include',
30
33
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
31
- body: 'id=' + encodeURIComponent(fullname)
32
- + '&dir=' + direction
33
- + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
34
+ body,
34
35
  });
36
+ // 投票是幂等的(同一方向重复提交结果不变),网关 5xx 重试一次是安全的。
37
+ let res = await post();
38
+ let attempts = 1;
39
+ if (res.status >= 500) {
40
+ await new Promise(resolve => setTimeout(resolve, 1500));
41
+ res = await post();
42
+ attempts = 2;
43
+ }
35
44
 
36
45
  const raw = await res.text();
37
46
  let data;
@@ -39,7 +48,7 @@ import{ArgumentError as a,CommandExecutionError as s}from"@jackwener/opencli/err
39
48
  const doc = new DOMParser().parseFromString(raw, 'text/html');
40
49
  doc.querySelectorAll('script,style').forEach(el => el.remove());
41
50
  const detail = doc.body.textContent.replace(/\\s+/g, ' ').trim().slice(0, 500);
42
- return { ok: false, message: 'HTTP ' + res.status + ': non-JSON response: ' + detail };
51
+ return { ok: false, message: 'HTTP ' + res.status + ': non-JSON response: ' + detail + (attempts > 1 ? ' (after 1 retry)' : '') };
43
52
  }
44
53
  if (!res.ok) {
45
54
  const detail = data?.json?.errors?.map(e => e.join(': ')).join('; ') || data?.message || data?.reason || data?.error || 'Reddit rejected request';
@@ -51,9 +60,23 @@ import{ArgumentError as a,CommandExecutionError as s}from"@jackwener/opencli/err
51
60
  }
52
61
  if (data?.error) return { ok: false, message: String(data.message || data.error).slice(0, 500) };
53
62
 
63
+ // 只读回查当前投票状态,回执里给出最终状态而不只是「请求已发出」。
64
+ let likes = 'unverified';
65
+ let points = null;
66
+ try {
67
+ const infoRes = await fetch('https://www.reddit.com/api/info.json?id=' + encodeURIComponent(fullname) + '&raw_json=1', { credentials: 'include' });
68
+ const info = await infoRes.json();
69
+ const thing = info?.data?.children?.[0]?.data;
70
+ if (thing && thing.name === fullname) {
71
+ likes = thing.likes === true ? 'up' : thing.likes === false ? 'down' : 'none';
72
+ points = typeof thing.score === 'number' ? thing.score : null;
73
+ }
74
+ } catch {}
75
+
54
76
  const labels = { '1': 'Upvoted', '-1': 'Downvoted', '0': 'Vote removed' };
55
- return { ok: true, message: (labels[String(direction)] || 'Voted') + ' ' + fullname };
77
+ // 中间对象的键故意不与 columns 重名(silent-column-drop 审计按键名判定输出行)。
78
+ return { ok: true, fullname, likes, points, attempts, summary: (labels[String(direction)] || 'Voted') + ' ' + fullname };
56
79
  } catch (e) {
57
80
  return { ok: false, message: e.toString() };
58
81
  }
59
- })()`);if(!t?.ok)throw new s(t?.message||"Reddit vote returned no result","403 时停止写入并检查平台提示;网络中断时先回查当前投票状态,不要循环重试。");return[{status:"success",message:t.message}]}});
82
+ })()`);if(!e?.ok)throw new r(e?.message||"Reddit vote returned no result","403 时停止写入并检查平台提示;网络中断时先回查当前投票状态,不要循环重试。");const n=e.likes!=="unverified"&&e.likes!==t,a=e.likes==="unverified"?"; current vote state could not be read back":n?`; Reddit accepted the request but currently reports vote_state=${e.likes} — do not loop, check again later`:"";return[{status:"success",message:e.summary+(e.attempts>1?" (succeeded on retry after a 5xx)":"")+a,target:e.fullname,direction:t,vote_state:e.likes,score:e.points}]}});
@@ -1,20 +1 @@
1
- import{cli as e,Strategy as t}from"@jackwener/opencli/registry";e({site:"reddit",name:"user-comments",access:"read",description:"View a Reddit user's comment history",domain:"reddit.com",strategy:t.COOKIE,browser:!0,args:[{name:"username",type:"string",required:!0,positional:!0,help:"Reddit username (no `u/` prefix needed)"},{name:"limit",type:"int",default:15}],columns:["subreddit","score","body","url"],pipeline:[{navigate:"https://www.reddit.com"},{evaluate:`(async () => {
2
- const username = \${{ args.username | json }};
3
- const name = username.startsWith('u/') ? username.slice(2) : username;
4
- const limit = \${{ args.limit }};
5
- const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', {
6
- credentials: 'include'
7
- });
8
- const d = await res.json();
9
- return (d?.data?.children || []).map(c => {
10
- let body = c.data.body || '';
11
- if (body.length > 300) body = body.slice(0, 300) + '...';
12
- return {
13
- subreddit: c.data.subreddit_name_prefixed,
14
- score: c.data.score,
15
- body: body,
16
- url: 'https://www.reddit.com' + c.data.permalink,
17
- };
18
- });
19
- })()
20
- `},{map:{subreddit:"${{ item.subreddit }}",score:"${{ item.score }}",body:"${{ item.body }}",url:"${{ item.url }}"}},{limit:"${{ args.limit }}"}]});
1
+ import{CommandExecutionError as a,EmptyResultError as m}from"@jackwener/opencli/errors";import{cli as u,Strategy as c}from"@jackwener/opencli/registry";import{listingChildren as l,normalizeRedditUsername as y,redditGetJson as w,requirePositiveInt as p}from"./shared.js";u({site:"reddit",name:"user-comments",access:"read",description:"View a Reddit user's comment history",domain:"reddit.com",strategy:c.COOKIE,browser:!0,args:[{name:"username",type:"string",required:!0,positional:!0,help:"Reddit username (no `u/` prefix needed)"},{name:"limit",type:"int",default:15}],columns:["id","subreddit","score","body","url","link_id","created_utc","removed_by_category"],func:async(r,d)=>{if(!r)throw new a("Browser session required");const i=y(d.username),o=p(d.limit,"--limit",15);await r.goto("https://www.reddit.com");const s=await w(r,`/user/${encodeURIComponent(i)}/comments.json?limit=${o}&raw_json=1`,{label:"reddit user-comments",username:i}),n=l(s,"reddit user-comments").filter((e)=>e?.kind==="t1").slice(0,o).map((e)=>{let t=e.data.body||"";if(t.length>300)t=t.slice(0,300)+"...";return{id:e.data.id,subreddit:e.data.subreddit_name_prefixed,score:e.data.score,body:t,url:"https://www.reddit.com"+e.data.permalink,link_id:e.data.link_id,created_utc:e.data.created_utc,removed_by_category:e.data.removed_by_category??null}});if(!n.length)throw new m("reddit user-comments",`u/${i} has no comments visible to this account.`);return n}});
@@ -1,17 +1 @@
1
- import{cli as e,Strategy as t}from"@jackwener/opencli/registry";e({site:"reddit",name:"user-posts",access:"read",description:"View a Reddit user's submitted posts",domain:"reddit.com",strategy:t.COOKIE,browser:!0,args:[{name:"username",type:"string",required:!0,positional:!0,help:"Reddit username (no `u/` prefix needed)"},{name:"limit",type:"int",default:15}],columns:["title","subreddit","score","comments","url"],pipeline:[{navigate:"https://www.reddit.com"},{evaluate:`(async () => {
2
- const username = \${{ args.username | json }};
3
- const name = username.startsWith('u/') ? username.slice(2) : username;
4
- const limit = \${{ args.limit }};
5
- const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', {
6
- credentials: 'include'
7
- });
8
- const d = await res.json();
9
- return (d?.data?.children || []).map(c => ({
10
- title: c.data.title,
11
- subreddit: c.data.subreddit_name_prefixed,
12
- score: c.data.score,
13
- comments: c.data.num_comments,
14
- url: 'https://www.reddit.com' + c.data.permalink,
15
- }));
16
- })()
17
- `},{map:{title:"${{ item.title }}",subreddit:"${{ item.subreddit }}",score:"${{ item.score }}",comments:"${{ item.comments }}",url:"${{ item.url }}"}},{limit:"${{ args.limit }}"}]});
1
+ import{CommandExecutionError as n,EmptyResultError as m}from"@jackwener/opencli/errors";import{cli as a,Strategy as u}from"@jackwener/opencli/registry";import{listingChildren as l,normalizeRedditUsername as c,redditGetJson as p,requirePositiveInt as w}from"./shared.js";a({site:"reddit",name:"user-posts",access:"read",description:"View a Reddit user's submitted posts",domain:"reddit.com",strategy:u.COOKIE,browser:!0,args:[{name:"username",type:"string",required:!0,positional:!0,help:"Reddit username (no `u/` prefix needed)"},{name:"limit",type:"int",default:15}],columns:["title","subreddit","score","comments","url"],func:async(t,i)=>{if(!t)throw new n("Browser session required");const r=c(i.username),s=w(i.limit,"--limit",15);await t.goto("https://www.reddit.com");const d=await p(t,`/user/${encodeURIComponent(r)}/submitted.json?limit=${s}&raw_json=1`,{label:"reddit user-posts",username:r}),o=l(d,"reddit user-posts").filter((e)=>e?.kind==="t3").slice(0,s).map((e)=>({title:e.data.title,subreddit:e.data.subreddit_name_prefixed,score:e.data.score,comments:e.data.num_comments,url:"https://www.reddit.com"+e.data.permalink}));if(!o.length)throw new m("reddit user-posts",`u/${r} has no posts visible to this account.`);return o}});