publishport-opencli 1.0.5 → 1.0.7

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/cli-manifest.json CHANGED
@@ -34440,7 +34440,8 @@
34440
34440
  "likes",
34441
34441
  "time",
34442
34442
  "is_reply",
34443
- "reply_to"
34443
+ "reply_to",
34444
+ "images"
34444
34445
  ],
34445
34446
  "type": "js",
34446
34447
  "modulePath": "rednote/comments.js",
@@ -40283,6 +40284,46 @@
40283
40284
  "siteSession": "persistent",
40284
40285
  "defaultWindowMode": "foreground"
40285
40286
  },
40287
+ {
40288
+ "site": "toutiao",
40289
+ "name": "recommend",
40290
+ "description": "今日头条频道推荐流(公开 API,无需登录)",
40291
+ "access": "read",
40292
+ "domain": "www.toutiao.com",
40293
+ "strategy": "public",
40294
+ "browser": false,
40295
+ "args": [
40296
+ {
40297
+ "name": "category",
40298
+ "type": "string",
40299
+ "default": "__all__",
40300
+ "required": false,
40301
+ "help": "频道 (__all__, news_tech, news_finance, news_world, news_sports, news_entertainment, news_military)"
40302
+ },
40303
+ {
40304
+ "name": "limit",
40305
+ "type": "int",
40306
+ "default": 20,
40307
+ "required": false,
40308
+ "help": "返回条数 (1-50)"
40309
+ }
40310
+ ],
40311
+ "columns": [
40312
+ "rank",
40313
+ "group_id",
40314
+ "title",
40315
+ "abstract",
40316
+ "source",
40317
+ "tag",
40318
+ "comments",
40319
+ "published_at",
40320
+ "url",
40321
+ "image_url"
40322
+ ],
40323
+ "type": "js",
40324
+ "modulePath": "toutiao/recommend.js",
40325
+ "sourceFile": "toutiao/recommend.js"
40326
+ },
40286
40327
  {
40287
40328
  "site": "toutiao",
40288
40329
  "name": "whoami",
@@ -47794,7 +47835,8 @@
47794
47835
  "likes",
47795
47836
  "time",
47796
47837
  "is_reply",
47797
- "reply_to"
47838
+ "reply_to",
47839
+ "images"
47798
47840
  ],
47799
47841
  "type": "js",
47800
47842
  "modulePath": "xiaohongshu/comments.js",
@@ -1,4 +1,4 @@
1
- import{AuthRequiredError as i,CommandExecutionError as s}from"@jackwener/opencli/errors";import{registerSiteAuthCommands as r}from"../_shared/site-auth.js";async function a(e){return(await e.getCookies({url:"https://chatgpt.com"})).some((n)=>n.name==="__Secure-next-auth.session-token"&&n.value)}async function o(e){if(!await a(e))throw new i("chatgpt.com","ChatGPT __Secure-next-auth.session-token cookie missing");await e.goto("https://chatgpt.com/");await e.wait(2);const t=await e.evaluate(`(async () => {
1
+ import{AuthRequiredError as G,CommandExecutionError as D}from"@jackwener/opencli/errors";import{registerSiteAuthCommands as K}from"../_shared/site-auth.js";async function H(B){return(await B.getCookies({url:"https://chatgpt.com"})).some((F)=>F.name.startsWith("__Secure-next-auth.session-token")&&F.value)}async function J(B){await B.goto("https://chatgpt.com/");await B.wait(2);const z=await B.evaluate(`(async () => {
2
2
  try {
3
3
  const res = await fetch('/api/auth/session', { credentials: 'include' });
4
4
  if (res.status === 401 || res.status === 403) {
@@ -14,4 +14,4 @@ import{AuthRequiredError as i,CommandExecutionError as s}from"@jackwener/opencli
14
14
  } catch (e) {
15
15
  return { kind: 'exception', detail: String(e && e.message || e) };
16
16
  }
17
- })()`);if(t?.kind==="auth")throw new i("chatgpt.com",t.detail);if(t?.kind==="http")throw new s(`HTTP ${t.httpStatus} from /api/auth/session`);if(t?.kind==="exception")throw new s(`ChatGPT whoami failed: ${t.detail}`);if(!t?.ok)throw new s(`Unexpected ChatGPT probe: ${JSON.stringify(t)}`);return{user_id:t.user_id,name:t.name}}r({site:"chatgpt",domain:"chatgpt.com",loginUrl:"https://auth.openai.com/log-in",columns:["user_id","name"],quickCheck:a,verify:o,poll:async(e)=>{if(!await a(e))throw new i("chatgpt.com","Waiting for ChatGPT session cookie");return o(e)}});
17
+ })()`);if(z?.kind==="auth")throw new G("chatgpt.com",z.detail);if(z?.kind==="http")throw new D(`HTTP ${z.httpStatus} from /api/auth/session`);if(z?.kind==="exception")throw new D(`ChatGPT whoami failed: ${z.detail}`);if(!z?.ok)throw new D(`Unexpected ChatGPT probe: ${JSON.stringify(z)}`);return{user_id:z.user_id,name:z.name}}K({site:"chatgpt",domain:"chatgpt.com",loginUrl:"https://auth.openai.com/log-in",columns:["user_id","name"],quickCheck:H,verify:J,poll:async(B)=>{if(!await H(B))throw new G("chatgpt.com","Waiting for ChatGPT session cookie");return J(B)}});
@@ -1 +1 @@
1
- import*as H from"node:os";import*as $ from"node:path";import*as I from"node:fs";import{cli as f,Strategy as E}from"@jackwener/opencli/registry";import{saveBase64ToFile as T}from"@jackwener/opencli/utils";import{ArgumentError as y,CommandExecutionError as _,EmptyResultError as v}from"@jackwener/opencli/errors";import{clearChatGPTDraft as C,getChatGPTVisibleImageUrls as x,navigateToProject as k,normalizeBooleanFlag as c,prepareChatGPTImagePaths as w,sendChatGPTMessage as P,unwrapEvaluateResult as h,waitForChatGPTImages as d,getChatGPTImageAssets as u,uploadChatGPTImages as n}from"./utils.js";const N="chatgpt.com";function m(q){if(q.includes("png"))return".png";if(q.includes("webp"))return".webp";if(q.includes("gif"))return".gif";return".jpg"}function l(q){const J=H.homedir();return q.startsWith(J)?`~${q.slice(J.length)}`:q}export function resolveOutputDir(q){const J=String(q||"").trim();if(!J)return $.join(H.homedir(),"Pictures","chatgpt");if(J==="~")return H.homedir();if(J.startsWith("~/"))return $.join(H.homedir(),J.slice(2));return $.resolve(J)}export function nextAvailablePath(q,J,z,W=I.existsSync){let Y=$.join(q,`${J}${z}`);for(let X=1;W(Y);X+=1)Y=$.join(q,`${J}_${X}${z}`);return Y}export function parseImagePaths(q){if(Array.isArray(q))return q.flatMap((J)=>parseImagePaths(J));return String(q??"").split(",").map((J)=>J.trim()).filter(Boolean)}function g(q,J){if(J>0)return`Edit the attached image${J===1?"":"s"}: ${q}`;return`Generate an image of: ${q}`}async function F(q){const J=h(await q.evaluate("window.location.href").catch(()=>""));return typeof J==="string"&&J?J:"https://chatgpt.com"}export const imageCommand=f({site:"chatgpt",name:"image",access:"write",description:"Generate images with ChatGPT web and save them locally",domain:N,strategy:E.COOKIE,browser:!0,siteSession:"persistent",navigateBefore:!1,defaultFormat:"plain",args:[{name:"prompt",positional:!0,required:!0,help:"Image prompt to send to ChatGPT"},{name:"image",help:"Local image path to attach before prompting; comma-separated paths are supported"},{name:"project",valueRequired:!0,help:"Start image generation inside a ChatGPT project ID or /g/g-p-<id> URL"},{name:"op",help:"Output directory (default: ~/Pictures/chatgpt)"},{name:"sd",type:"boolean",default:!1,help:"Skip download shorthand; only show ChatGPT link"},{name:"timeout",type:"int",required:!1,default:240,help:"Max seconds for the overall command (default: 240)"}],columns:["status","file","link"],func:async(q,J)=>{const z=J.prompt,W=parseImagePaths(J.image),Y=resolveOutputDir(J.op),X=J.sd,R=X===""||X===!0||c(X),O=J.timeout;if(!Number.isInteger(O)||O<1)throw new y("--timeout must be a positive integer (seconds)");const S=W.length?await w(W):{ok:!0,paths:[]};if(!S.ok)throw new y(S.reason);if(J.project)await k(q,J.project);else await q.goto(`https://${N}/new`,{settleMs:2000});await C(q);if(W.length){let K;try{K=await n(q,S.paths)}catch(Q){throw new _(`Failed to upload image to ChatGPT: ${Q instanceof Error?Q.message:String(Q)}`)}if(!K?.ok)throw new _(K?.reason||"Failed to upload image to ChatGPT")}const A=await x(q);if(!await P(q,g(z,W.length)))throw new _("Failed to send image prompt to ChatGPT",`Open ${await F(q)} and verify the composer is ready.`);let Z="";for(let K=0;K<10;K++){const Q=await F(q);if(Q.includes("/c/")){Z=Q;break}await q.wait(2)}if(!Z)Z=await F(q);const L=await d(q,A,O,Z),V=Z;if(!L.length)throw new v("chatgpt image",`No generated images were detected before timeout. Open ${V} and verify whether ChatGPT finished generating the image.`);if(R)return[{status:"\uD83C\uDFA8 generated",file:"\uD83D\uDCC1 -",link:`\uD83D\uDD17 ${V}`}];const B=await u(q,L);if(!B.length)throw new _("Failed to export generated ChatGPT image assets",`Open ${V} and verify the generated images are visible, then retry.`);const U=Date.now(),M=[];for(let K=0;K<B.length;K+=1){const Q=B[K],D=Q.dataUrl.replace(/^data:[^;]+;base64,/,""),b=B.length>1?`_${K+1}`:"",G=m(Q.mimeType),j=nextAvailablePath(Y,`chatgpt_${U}${b}`,G);await T(D,j);M.push({status:"✅ saved",file:`\uD83D\uDCC1 ${l(j)}`,link:`\uD83D\uDD17 ${V}`})}return M}});
1
+ import*as H from"node:os";import*as $ from"node:path";import*as I from"node:fs";import{cli as f,Strategy as E}from"@jackwener/opencli/registry";import{saveBase64ToFile as T}from"@jackwener/opencli/utils";import{ArgumentError as y,CommandExecutionError as _,EmptyResultError as v}from"@jackwener/opencli/errors";import{clearChatGPTDraft as C,getChatGPTVisibleImageUrls as x,navigateToProject as k,normalizeBooleanFlag as c,prepareChatGPTImagePaths as w,sendChatGPTMessage as P,unwrapEvaluateResult as h,waitForChatGPTImages as d,getChatGPTImageAssets as u,uploadChatGPTImages as n}from"./utils.js";const N="chatgpt.com";function m(q){if(q.includes("png"))return".png";if(q.includes("webp"))return".webp";if(q.includes("gif"))return".gif";return".jpg"}function l(q){const J=H.homedir();return q.startsWith(J)?`~${q.slice(J.length)}`:q}export function resolveOutputDir(q){const J=String(q||"").trim();if(!J)return $.join(H.homedir(),"Pictures","chatgpt");if(J==="~")return H.homedir();if(J.startsWith("~/"))return $.join(H.homedir(),J.slice(2));return $.resolve(J)}export function nextAvailablePath(q,J,z,W=I.existsSync){let Y=$.join(q,`${J}${z}`);for(let X=1;W(Y);X+=1)Y=$.join(q,`${J}_${X}${z}`);return Y}export function parseImagePaths(q){if(Array.isArray(q))return q.flatMap((J)=>parseImagePaths(J));return String(q??"").split(",").map((J)=>J.trim()).filter(Boolean)}function g(q,J){if(J>0)return`Edit the attached image${J===1?"":"s"}: ${q}`;return`Generate an image of: ${q}`}async function F(q){const J=h(await q.evaluate("window.location.href").catch(()=>""));return typeof J==="string"&&J?J:"https://chatgpt.com"}export const imageCommand=f({site:"chatgpt",name:"image",access:"write",description:"Generate images with ChatGPT web and save them locally",domain:N,strategy:E.COOKIE,browser:!0,siteSession:"persistent",navigateBefore:!1,defaultFormat:"plain",args:[{name:"prompt",positional:!0,required:!0,help:"Image prompt to send to ChatGPT"},{name:"image",help:"Local image path to attach before prompting; comma-separated paths are supported"},{name:"project",valueRequired:!0,help:"Start image generation inside a ChatGPT project ID or /g/g-p-<id> URL"},{name:"op",help:"Output directory (default: ~/Pictures/chatgpt)"},{name:"sd",type:"boolean",default:!1,help:"Skip download shorthand; only show ChatGPT link"},{name:"timeout",type:"int",required:!1,default:240,help:"Max seconds for the overall command (default: 240)"}],columns:["status","file","link"],func:async(q,J)=>{const z=J.prompt,W=parseImagePaths(J.image),Y=resolveOutputDir(J.op),X=J.sd,R=X===""||X===!0||c(X),O=J.timeout;if(!Number.isInteger(O)||O<1)throw new y("--timeout must be a positive integer (seconds)");const S=W.length?await w(W):{ok:!0,paths:[]};if(!S.ok)throw new y(S.reason);if(J.project)await k(q,J.project);else await q.goto(`https://${N}/new`,{settleMs:2000});await C(q);if(W.length){let K;try{K=await n(q,S.paths)}catch(Q){throw new _(`Failed to upload image to ChatGPT: ${Q instanceof Error?Q.message:String(Q)}`)}if(!K?.ok)throw new _(K?.reason||"Failed to upload image to ChatGPT")}const A=await x(q);if(!await P(q,g(z,W.length)))throw new _("Failed to send image prompt to ChatGPT",`Open ${await F(q)} and verify the composer is ready.`);let Z="";for(let K=0;K<10;K++){const Q=await F(q);if(Q.includes("/c/")){Z=Q;break}await q.sleep(2)}if(!Z)Z=await F(q);const L=await d(q,A,O,Z),V=Z;if(!L.length)throw new v("chatgpt image",`No generated images were detected before timeout. Open ${V} and verify whether ChatGPT finished generating the image.`);if(R)return[{status:"\uD83C\uDFA8 generated",file:"\uD83D\uDCC1 -",link:`\uD83D\uDD17 ${V}`}];const B=await u(q,L);if(!B.length)throw new _("Failed to export generated ChatGPT image assets",`Open ${V} and verify the generated images are visible, then retry.`);const U=Date.now(),M=[];for(let K=0;K<B.length;K+=1){const Q=B[K],D=Q.dataUrl.replace(/^data:[^;]+;base64,/,""),b=B.length>1?`_${K+1}`:"",G=m(Q.mimeType),j=nextAvailablePath(Y,`chatgpt_${U}${b}`,G);await T(D,j);M.push({status:"✅ saved",file:`\uD83D\uDCC1 ${l(j)}`,link:`\uD83D\uDD17 ${V}`})}return M}});
@@ -701,7 +701,7 @@ import{htmlToMarkdown as i}from"@jackwener/opencli/utils";import{ArgumentError a
701
701
  }
702
702
  return urls;
703
703
  })()
704
- `)),"chatgpt visible image url extraction")}export async function waitForChatGPTImages(Q,X,Y,Z){const $=new Set(X),W=3,V=Math.max(1,Math.ceil(Y/W));let z=[],q=0;for(let j=0;j<V;j++){await Q.wait(j===0?3:W);let J="";if(Z&&Z.includes("/c/")){J=unwrapEvaluateResult(await Q.evaluate("window.location.href").catch(()=>""));if(J&&!N(J,Z)){await Q.goto(Z);await Q.wait(3)}}if(await isGenerating(Q))continue;if(Z&&Z.includes("/c/")&&j>0&&j%5===0){if(!J||N(J,Z)){await Q.goto(Z);await Q.wait(3)}}const B=(await getChatGPTVisibleImageUrls(Q)).filter((b)=>!$.has(b));if(B.length===0)continue;const H=B.join(`
704
+ `)),"chatgpt visible image url extraction")}export async function waitForChatGPTImages(Q,X,Y,Z){const $=new Set(X),W=3,V=Math.max(1,Math.ceil(Y/W));let z=[],q=0;for(let j=0;j<V;j++){await Q.wait(j===0?3:W);let J="";if(Z&&Z.includes("/c/")){J=unwrapEvaluateResult(await Q.evaluate("window.location.href").catch(()=>""));if(J&&!N(J,Z)){await Q.goto(Z);await Q.sleep(3)}}if(await isGenerating(Q))continue;if(Z&&Z.includes("/c/")&&j>0&&j%5===0){if(!J||N(J,Z)){await Q.goto(Z);await Q.sleep(3)}}const B=(await getChatGPTVisibleImageUrls(Q)).filter((b)=>!$.has(b));if(B.length===0)continue;const H=B.join(`
705
705
  `),D=z.join(`
706
706
  `);if(H===D)q+=1;else{z=B;q=1}if(q>=2||j===V-1)return z}return z}export async function getProjectList(Q){await ensureOnChatGPT(Q);if(requireBooleanEvaluateResult(unwrapEvaluateResult(await Q.evaluate(`(() => {
707
707
  const button = Array.from(document.querySelectorAll('button'))
@@ -9,19 +9,44 @@ import{cli as e}from"@jackwener/opencli/registry";e({site:"instagram",name:"expl
9
9
  );
10
10
  if (!res.ok) throw new Error('HTTP ' + res.status + ' - make sure you are logged in to Instagram');
11
11
  const data = await res.json();
12
- const posts = [];
13
- for (const sec of (data?.sectional_items || [])) {
14
- for (const m of (sec?.layout_content?.medias || [])) {
15
- const media = m?.media;
16
- if (media) posts.push({
17
- user: media.user?.username || '',
18
- caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
19
- likes: media.like_count ?? 0,
20
- comments: media.comment_count ?? 0,
21
- type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
22
- });
12
+
13
+ // Instagram no longer populates the flat layout_content.medias[] path. Media
14
+ // objects are now nested across mixed layout shapes (one_by_two_item.clips.
15
+ // items[].media, fill_items[].media, etc.), so recursively walk each sectional
16
+ // item collecting every distinct node.media and dedupe by pk/id/code. See #2091.
17
+ const seen = new Set();
18
+ const medias = [];
19
+ const collect = (node, depth) => {
20
+ if (!node || typeof node !== 'object' || depth > 8) return;
21
+ if (Array.isArray(node)) {
22
+ for (const item of node) collect(item, depth + 1);
23
+ return;
23
24
  }
24
- }
25
+ const media = node.media;
26
+ if (media && typeof media === 'object' && !Array.isArray(media)) {
27
+ const key = media.pk ?? media.id ?? media.code;
28
+ if (key != null && !seen.has(key)) {
29
+ seen.add(key);
30
+ medias.push(media);
31
+ }
32
+ }
33
+ for (const [k, value] of Object.entries(node)) {
34
+ // Don't descend into a collected media object — a carousel's child items
35
+ // carry their own .media and would otherwise be counted as separate posts.
36
+ if (k === 'media') continue;
37
+ if (value && typeof value === 'object') collect(value, depth + 1);
38
+ }
39
+ };
40
+ for (const sec of (data?.sectional_items || [])) collect(sec, 0);
41
+
42
+ const posts = medias.map((media) => ({
43
+ user: media.user?.username || '',
44
+ caption: (media.caption?.text || '').replace(/\\n/g, ' ').substring(0, 100),
45
+ // Clips/reels report engagement via play_count rather than like_count.
46
+ likes: media.like_count ?? media.play_count ?? 0,
47
+ comments: media.comment_count ?? 0,
48
+ type: media.media_type === 1 ? 'photo' : media.media_type === 2 ? 'video' : 'carousel',
49
+ }));
25
50
  return posts.slice(0, limit).map((p, i) => ({ rank: i + 1, ...p }));
26
51
  })()
27
52
  `}]});
@@ -1 +1 @@
1
- import{cli as p,Strategy as f}from"@jackwener/opencli/registry";import{ArgumentError as u,AuthRequiredError as h,CommandExecutionError as w,EmptyResultError as y}from"@jackwener/opencli/errors";import{buildCommentsExtractJs as b}from"../xiaohongshu/comments.js";import{buildNoteUrl as g,parseNoteId as x}from"../xiaohongshu/note-helpers.js";const R="Pass a full rednote.com note URL with xsec_token from search results or user/profile context.";function _(o){const e=Number(o??20);if(!Number.isFinite(e)||!Number.isInteger(e))throw new u(`--limit must be an integer between 1 and 50, got ${JSON.stringify(o)}`);if(e<1||e>50)throw new u(`--limit must be between 1 and 50, got ${e}`);return e}p({site:"rednote",name:"comments",access:"read",description:"Read comments from a rednote note (supports nested replies)",domain:"www.rednote.com",strategy:f.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full rednote note URL with xsec_token"},{name:"limit",type:"int",default:20,help:"Number of top-level comments (max 50)"},{name:"with-replies",type:"boolean",default:!1,help:"Include nested replies (楼中楼)"}],columns:["rank","author","text","likes","time","is_reply","reply_to"],func:async(o,e)=>{const s=_(e.limit),a=Boolean(e["with-replies"]),i=String(e["note-id"]),d=x(i);await o.goto(g(i,{commandName:"rednote comments",cookieRoot:"rednote.com",signedUrlHint:R}));await o.wait({time:2+Math.random()*3});const r=await o.evaluate(b(a));if(!r||typeof r!=="object")throw new y("rednote/comments","Unexpected evaluate response");if(r.securityBlock)throw new w("Rednote security block: the note detail page was blocked by risk control.",/^https?:\/\//.test(i)?"The page may be temporarily restricted. Try again later or from a different session.":"Try using a full URL from search results (with xsec_token) instead of a bare note ID.");if(r.loginWall)throw new h("www.rednote.com","Note comments require login");const m=r.results??[],l=(t,n)=>({rank:n+1,author:t.author,text:t.text,likes:t.likes,time:t.time,is_reply:t.is_reply,reply_to:t.reply_to});if(a){const t=[];let n=0;for(const c of m){if(!c.is_reply)n++;if(n>s)break;t.push(c)}return t.map(l)}return m.slice(0,s).map(l)}});
1
+ import{cli as p,Strategy as f}from"@jackwener/opencli/registry";import{ArgumentError as u,AuthRequiredError as h,CommandExecutionError as w,EmptyResultError as y}from"@jackwener/opencli/errors";import{buildCommentsExtractJs as g,normalizeCommentRows as b}from"../xiaohongshu/comments.js";import{buildNoteUrl as R,parseNoteId as x}from"../xiaohongshu/note-helpers.js";const _="Pass a full rednote.com note URL with xsec_token from search results or user/profile context.";function k(o){const e=Number(o??20);if(!Number.isFinite(e)||!Number.isInteger(e))throw new u(`--limit must be an integer between 1 and 50, got ${JSON.stringify(o)}`);if(e<1||e>50)throw new u(`--limit must be between 1 and 50, got ${e}`);return e}p({site:"rednote",name:"comments",access:"read",description:"Read comments from a rednote note (supports nested replies)",domain:"www.rednote.com",strategy:f.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full rednote note URL with xsec_token"},{name:"limit",type:"int",default:20,help:"Number of top-level comments (max 50)"},{name:"with-replies",type:"boolean",default:!1,help:"Include nested replies (楼中楼)"}],columns:["rank","author","text","likes","time","is_reply","reply_to","images"],func:async(o,e)=>{const i=k(e.limit),m=Boolean(e["with-replies"]),s=String(e["note-id"]),d=x(s);await o.goto(R(s,{commandName:"rednote comments",cookieRoot:"rednote.com",signedUrlHint:_}));await o.wait({time:2+Math.random()*3});const r=await o.evaluate(g(m,i));if(!r||typeof r!=="object")throw new y("rednote/comments","Unexpected evaluate response");if(r.securityBlock)throw new w("Rednote security block: the note detail page was blocked by risk control.",/^https?:\/\//.test(s)?"The page may be temporarily restricted. Try again later or from a different session.":"Try using a full URL from search results (with xsec_token) instead of a bare note ID.");if(r.loginWall)throw new h("www.rednote.com","Note comments require login");const a=b(r.results,"rednote/comments"),l=(t,n)=>({rank:n+1,author:t.author,text:t.text,likes:t.likes,time:t.time,is_reply:t.is_reply,reply_to:t.reply_to,images:t.images??[]});if(m){const t=[];let n=0;for(const c of a){if(!c.is_reply)n++;if(n>i)break;t.push(c)}return t.map(l)}return a.slice(0,i).map(l)}});
@@ -1 +1 @@
1
- import{cli as M,Strategy as P}from"@jackwener/opencli/registry";import{CommandExecutionError as z,EmptyResultError as Q}from"@jackwener/opencli/errors";import{HOT_BOARD_URL as V,mapHotRow as W,parseHotLimit as X}from"./utils.js";M({site:"toutiao",name:"hot",access:"read",description:"今日头条首页热榜(公开 API,无需登录)",domain:"www.toutiao.com",strategy:P.PUBLIC,browser:!1,args:[{name:"limit",type:"int",default:30,help:"返回条数 (1-50)"}],columns:["rank","group_id","title","query","hot_value","label","url","image_url"],func:async(Y,I)=>{const J=X(I?.limit,30);let F;try{F=await fetch(V,{headers:{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",Accept:"application/json",Referer:"https://www.toutiao.com/"}})}catch(q){throw new z(`toutiao hot-board request failed: ${q?.message||q}`)}if(!F.ok)throw new z(`toutiao hot-board failed: HTTP ${F.status}`);let b;try{b=await F.json()}catch(q){throw new z(`toutiao hot-board returned malformed JSON: ${q?.message||q}`)}if(b?.status&&b.status!=="success")throw new z(`toutiao hot-board returned status=${b.status}`);if(b?.error||b?.message)throw new z(`toutiao hot-board returned error: ${b.error||b.message}`);const G=(Array.isArray(b?.data)?b.data:[]).map(W).filter(Boolean).slice(0,J);if(G.length===0)throw new Q("toutiao hot","上游 hot-board 返回空列表。");return G.map((q,K)=>({...q,rank:K+1}))}});
1
+ import{cli as M,Strategy as P}from"@jackwener/opencli/registry";import{CommandExecutionError as z,EmptyResultError as Q}from"@jackwener/opencli/errors";import{HOT_BOARD_URL as V,mapHotRow as W,parseHotLimit as X}from"./utils.js";M({site:"toutiao",name:"hot",access:"read",description:"今日头条首页热榜(公开 API,无需登录)",domain:"www.toutiao.com",strategy:P.PUBLIC,browser:!1,args:[{name:"limit",type:"int",default:30,help:"返回条数 (1-50)"}],columns:["rank","group_id","title","query","hot_value","label","url","image_url"],func:async(I)=>{const J=X(I?.limit,30);let F;try{F=await fetch(V,{headers:{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",Accept:"application/json",Referer:"https://www.toutiao.com/"}})}catch(q){throw new z(`toutiao hot-board request failed: ${q?.message||q}`)}if(!F.ok)throw new z(`toutiao hot-board failed: HTTP ${F.status}`);let b;try{b=await F.json()}catch(q){throw new z(`toutiao hot-board returned malformed JSON: ${q?.message||q}`)}if(b?.status&&b.status!=="success")throw new z(`toutiao hot-board returned status=${b.status}`);if(b?.error||b?.message)throw new z(`toutiao hot-board returned error: ${b.error||b.message}`);const G=(Array.isArray(b?.data)?b.data:[]).map(W).filter(Boolean).slice(0,J);if(G.length===0)throw new Q("toutiao hot","上游 hot-board 返回空列表。");return G.map((q,K)=>({...q,rank:K+1}))}});
@@ -0,0 +1 @@
1
+ import{cli as V,Strategy as W}from"@jackwener/opencli/registry";import{CommandExecutionError as z,EmptyResultError as X}from"@jackwener/opencli/errors";import{RECOMMEND_CATEGORIES as Y,RECOMMEND_URL as Z,mapRecommendRow as $,parseRecommendCategory as h,parseRecommendLimit as v}from"./utils.js";V({site:"toutiao",name:"recommend",access:"read",description:"今日头条频道推荐流(公开 API,无需登录)",domain:"www.toutiao.com",strategy:W.PUBLIC,browser:!1,args:[{name:"category",type:"string",default:"__all__",help:`频道 (${Y.join(", ")})`},{name:"limit",type:"int",default:20,help:"返回条数 (1-50)"}],columns:["rank","group_id","title","abstract","source","tag","comments","published_at","url","image_url"],func:async(F)=>{const H=h(F?.category,"__all__"),K=v(F?.limit,20),P=`${Z}?category=${encodeURIComponent(H)}`;let B;try{B=await fetch(P,{headers:{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",Accept:"application/json",Referer:"https://www.toutiao.com/"}})}catch(b){throw new z(`toutiao recommend request failed: ${b?.message||b}`)}if(!B.ok)throw new z(`toutiao recommend failed: HTTP ${B.status}`);let q;try{q=await B.json()}catch(b){throw new z(`toutiao recommend returned malformed JSON: ${b?.message||b}`)}if(q?.message&&q.message!=="success")throw new z(`toutiao recommend returned message=${q.message}`);if(!Array.isArray(q?.data))throw new z("toutiao recommend returned a non-array data field");const J=q.data.map($).filter(Boolean).slice(0,K);if(J.length===0)throw new X("toutiao recommend",`频道 ${H} 返回空列表。`);return J.map((b,Q)=>({...b,rank:Q+1}))}});
@@ -1,2 +1,2 @@
1
- import{ArgumentError as V}from"@jackwener/opencli/errors";const W=1,Y=4,Z=1,$=50;export function parseArticlesPage(q,z=1){if(q===void 0||q===null||q==="")return z;const B=Number(q);if(!Number.isFinite(B)||!Number.isInteger(B))throw new V(`--page must be an integer between ${W} and ${Y}, got ${JSON.stringify(q)}`);if(B<W||B>Y)throw new V(`--page must be between ${W} and ${Y}, got ${B}`);return B}export function parseHotLimit(q,z=30){if(q===void 0||q===null||q==="")return z;const B=Number(q);if(!Number.isFinite(B)||!Number.isInteger(B))throw new V(`--limit must be an integer between ${Z} and ${$}, got ${JSON.stringify(q)}`);if(B<Z||B>$)throw new V(`--limit must be between ${Z} and ${$}, got ${B}`);return B}const x=new Set(["展现","阅读","点赞","评论","查看数据","查看评论","修改","更多","首发","已发布","定时发布","定时发布中","由文章生成","审核中"]),C=/展现\s*([\d,]+)\s*阅读\s*([\d,]+)\s*点赞\s*([\d,]+)\s*评论\s*([\d,]*)/;export function parseToutiaoArticlesText(q){const z=String(q||"").split(`
2
- `).map((D)=>D.trim()).filter(Boolean),B=[];for(let D=0;D<z.length;D++){const y=z[D];if(!/^\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(y))continue;const h=y;let U=null,j=null,g=null;for(let J=3;J>=1;J--){const F=z[D-J]||"";if(!F||F.length>=100||/^\d+$/.test(F)||x.has(F))continue;U=F;break}for(let J=1;J<8;J++){const F=z[D+J]||"";if(F==="已发布"||F==="定时发布中"||F==="审核中"||F==="由文章生成")j=F;if(F.includes("展现")&&F.includes("阅读")){const K=F.match(C);if(K)g={"展现":K[1],"阅读":K[2],"点赞":K[3],"评论":K[4]||"0"}}}if(!U)continue;if(g)B.push({title:U,date:h,status:j,...g});else B.push({title:U,date:h,status:j,"展现":null,"阅读":null,"点赞":null,"评论":null})}return B}function Q(q){const z=String(q??"").trim();return z?z:null}function G(q){const z=q?.Image?.url;if(typeof z==="string"&&z)return z;return(Array.isArray(q?.Image?.url_list)?q.Image.url_list.map((D)=>typeof D==="string"?D:D?.url).find((D)=>typeof D==="string"&&D):null)||null}function X(q){const z=Number(q);return Number.isFinite(z)&&z>=0?z:null}export function mapHotRow(q,z){if(!q||typeof q!=="object")return null;const B=Q(q.ClusterIdStr||(q.ClusterId!=null?String(q.ClusterId):null)),D=Q(q.Title);if(!D)return null;return{rank:z+1,group_id:B,title:D,query:Q(q.QueryWord)||D,hot_value:X(q.HotValue),label:Q(q.Label),url:Q(q.Url),image_url:G(q)}}export const HOT_BOARD_URL="https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc";export function looksToutiaoAuthWallText(q){const z=String(q||"").replace(/\s+/g," ").trim().toLowerCase();if(!z)return!1;return/登录|请登录|账号登录|扫码登录|安全验证|验证码|captcha/.test(z)||/\b(login|sign in|captcha|verification required)\b/.test(z)||/mp\.toutiao\.com\/profile_v4\/login/.test(z)}export const __test__={ARTICLES_MIN_PAGE:W,ARTICLES_MAX_PAGE:Y,HOT_MIN_LIMIT:Z,HOT_MAX_LIMIT:$};
1
+ import{ArgumentError as Z,CommandExecutionError as B}from"@jackwener/opencli/errors";const U=1,D=4,H=1,y=50,P=1,X=50;export function parseArticlesPage(q,z=1){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--page must be an integer between ${U} and ${D}, got ${JSON.stringify(q)}`);if(J<U||J>D)throw new Z(`--page must be between ${U} and ${D}, got ${J}`);return J}export function parseRecommendLimit(q,z=20){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--limit must be an integer between ${P} and ${X}, got ${JSON.stringify(q)}`);if(J<P||J>X)throw new Z(`--limit must be between ${P} and ${X}, got ${J}`);return J}export function parseRecommendCategory(q,z="__all__"){if(q===void 0||q===null||q==="")return z;const J=String(q).trim();if(!RECOMMEND_CATEGORIES.includes(J))throw new Z(`--category must be one of ${RECOMMEND_CATEGORIES.join(", ")}, got ${JSON.stringify(q)}`);return J}export function parseHotLimit(q,z=30){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--limit must be an integer between ${H} and ${y}, got ${JSON.stringify(q)}`);if(J<H||J>y)throw new Z(`--limit must be between ${H} and ${y}, got ${J}`);return J}const b=new Set(["展现","阅读","点赞","评论","查看数据","查看评论","修改","更多","首发","已发布","定时发布","定时发布中","由文章生成","审核中"]),g=/展现\s*([\d,]+)\s*阅读\s*([\d,]+)\s*点赞\s*([\d,]+)\s*评论\s*([\d,]*)/;export function parseToutiaoArticlesText(q){const z=String(q||"").split(`
2
+ `).map((K)=>K.trim()).filter(Boolean),J=[];for(let K=0;K<z.length;K++){const $=z[K];if(!/^\d{2}-\d{2}\s+\d{2}:\d{2}$/.test($))continue;const W=$;let F=null,h=null,k=null;for(let Y=3;Y>=1;Y--){const Q=z[K-Y]||"";if(!Q||Q.length>=100||/^\d+$/.test(Q)||b.has(Q))continue;F=Q;break}for(let Y=1;Y<8;Y++){const Q=z[K+Y]||"";if(Q==="已发布"||Q==="定时发布中"||Q==="审核中"||Q==="由文章生成")h=Q;if(Q.includes("展现")&&Q.includes("阅读")){const j=Q.match(g);if(j)k={"展现":j[1],"阅读":j[2],"点赞":j[3],"评论":j[4]||"0"}}}if(!F)continue;if(k)J.push({title:F,date:W,status:h,...k});else J.push({title:F,date:W,status:h,"展现":null,"阅读":null,"点赞":null,"评论":null})}return J}function V(q){const z=String(q??"").trim();return z?z:null}function v(q){const z=q?.Image?.url;if(typeof z==="string"&&z)return z;return(Array.isArray(q?.Image?.url_list)?q.Image.url_list.map((K)=>typeof K==="string"?K:K?.url).find((K)=>typeof K==="string"&&K):null)||null}function S(q){const z=Number(q);return Number.isFinite(z)&&z>=0?z:null}export function mapHotRow(q,z){if(!q||typeof q!=="object")return null;const J=V(q.ClusterIdStr||(q.ClusterId!=null?String(q.ClusterId):null)),K=V(q.Title);if(!K)return null;return{rank:z+1,group_id:J,title:K,query:V(q.QueryWord)||K,hot_value:S(q.HotValue),label:V(q.Label),url:V(q.Url),image_url:v(q)}}export const HOT_BOARD_URL="https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc",RECOMMEND_URL="https://www.toutiao.com/api/pc/feed/",RECOMMEND_CATEGORIES=["__all__","news_tech","news_finance","news_world","news_sports","news_entertainment","news_military"];function G(q){const z=V(q?.image_url)||V(q?.middle_image?.url);if(!z)return null;return z.startsWith("//")?`https:${z}`:z}function x(q){const z=V(q);if(!z)return null;try{const J=z.startsWith("/")?new URL(z,"https://www.toutiao.com"):new URL(z);if(!/^https?:$/i.test(J.protocol)||!/(^|\.)toutiao\.com$/i.test(J.hostname))throw new B(`Malformed toutiao recommend row: off-domain source_url ${JSON.stringify(z)}.`);return J}catch(J){if(J instanceof B)throw J;throw new B(`Malformed toutiao recommend row: invalid source_url ${JSON.stringify(z)}.`)}}function R(q){return String(q||"").match(/^\/(?:group|article)\/([A-Za-z0-9_-]+)\/?$/)?.[1]||""}function f(q,z){const J=x(q),K=J?R(J.pathname):"",$=V(z),W=$||V(K);if(!W)throw new B("Malformed toutiao recommend row: missing group_id and article source_url.");if(!/^[A-Za-z0-9_-]+$/.test(W))throw new B(`Malformed toutiao recommend row: invalid group_id ${JSON.stringify(z)}.`);if(K&&$&&$!==K)throw new B(`Malformed toutiao recommend row: group_id/source_url mismatch for ${JSON.stringify(z)}.`);return{groupId:W,url:`https://www.toutiao.com/group/${W}/`}}function C(q){const z=Number(q);if(!Number.isFinite(z)||z<=0)return null;return`${new Date(z*1000).toISOString().slice(0,19)}Z`}export function mapRecommendRow(q,z){if(!q||typeof q!=="object")return null;if(q.is_feed_ad)return null;const J=V(q.title);if(!J)return null;const K=f(q.source_url,q.group_id!=null?String(q.group_id):null);return{rank:z+1,group_id:K.groupId,title:J,abstract:V(q.abstract),source:V(q.source),tag:V(q.chinese_tag),comments:S(q.comments_count),published_at:C(q.behot_time),url:K.url,image_url:G(q)}}export function looksToutiaoAuthWallText(q){const z=String(q||"").replace(/\s+/g," ").trim().toLowerCase();if(!z)return!1;return/登录|请登录|账号登录|扫码登录|安全验证|验证码|captcha/.test(z)||/\b(login|sign in|captcha|verification required)\b/.test(z)||/mp\.toutiao\.com\/profile_v4\/login/.test(z)}export const __test__={ARTICLES_MIN_PAGE:U,ARTICLES_MAX_PAGE:D,HOT_MIN_LIMIT:H,HOT_MAX_LIMIT:y,RECOMMEND_MIN_LIMIT:P,RECOMMEND_MAX_LIMIT:X};
@@ -1,4 +1,4 @@
1
- import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli/errors";import{cli as $,Strategy as b}from"@jackwener/opencli/registry";import{resolveTwitterQueryId as K,describeTwitterApiError as L}from"./shared.js";import{TWITTER_BEARER_TOKEN as N}from"./utils.js";const O="7xflPyRiUxGVbJd4uWmbfg";$({site:"twitter",name:"article",access:"read",description:"Fetch a Twitter Article (long-form content) and export as Markdown",domain:"x.com",strategy:b.COOKIE,browser:!0,args:[{name:"tweet-id",type:"string",positional:!0,required:!0,help:"Tweet ID or URL containing the article"}],columns:["title","author","content","url"],func:async(f,P)=>{let j=P["tweet-id"];const V=/\/article\/\d+/.test(j),H=j.match(/\/(?:status|article)\/(\d+)/);if(H)j=H[1];if(V){await f.goto(`https://x.com/i/article/${j}`);await f.wait(3);const F=await f.evaluate(`
1
+ import{AuthRequiredError as Z,CommandExecutionError as H}from"@jackwener/opencli/errors";import{cli as N,Strategy as P}from"@jackwener/opencli/registry";import{resolveTwitterQueryId as b,describeTwitterApiError as k,unwrapBrowserResult as $}from"./shared.js";import{TWITTER_BEARER_TOKEN as B}from"./utils.js";const D="7xflPyRiUxGVbJd4uWmbfg";function M(f){return Boolean(f&&typeof f==="object"&&!Array.isArray(f))}N({site:"twitter",name:"article",access:"read",description:"Fetch a Twitter Article (long-form content) and export as Markdown",domain:"x.com",strategy:P.COOKIE,browser:!0,args:[{name:"tweet-id",type:"string",positional:!0,required:!0,help:"Tweet ID or URL containing the article"}],columns:["title","author","content","url"],func:async(f,j)=>{let F=j["tweet-id"];const K=/\/article\/\d+/.test(F),V=F.match(/\/(?:status|article)\/(\d+)/);if(V)F=V[1];if(K){await f.goto(`https://x.com/i/article/${F}`);await f.wait(3);const G=await f.evaluate(`
2
2
  (function() {
3
3
  var links = document.querySelectorAll('a[href*="/status/"]');
4
4
  for (var i = 0; i < links.length; i++) {
@@ -12,12 +12,12 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
12
12
  }
13
13
  return null;
14
14
  })()
15
- `);if(!F||typeof F!=="string")throw new G(`Could not resolve article ${j} to a tweet ID. The article page may not contain a linked tweet.`);j=F}await f.goto(`https://x.com/i/status/${j}`);await f.wait(3);const J=(await f.getCookies({url:"https://x.com"})).find((F)=>F.name==="ct0")?.value||null;if(!J)throw new Z("x.com","Not logged into x.com (no ct0 cookie)");const X=await K(f,"TweetResultByRestId",O),z=await f.evaluate(`
15
+ `),J=$(G);if(!J||typeof J!=="string")throw new H(`Could not resolve article ${F} to a tweet ID. The article page may not contain a linked tweet.`);F=J}await f.goto(`https://x.com/i/status/${F}`);await f.wait(3);const X=(await f.getCookies({url:"https://x.com"})).find((G)=>G.name==="ct0")?.value||null;if(!X)throw new Z("x.com","Not logged into x.com (no ct0 cookie)");const L=await b(f,"TweetResultByRestId",D),z=$(await f.evaluate(`
16
16
  async () => {
17
- const tweetId = "${j}";
18
- const ct0 = ${JSON.stringify(J)};
17
+ const tweetId = ${JSON.stringify(F)};
18
+ const ct0 = ${JSON.stringify(X)};
19
19
 
20
- const bearer = ${JSON.stringify(N)};
20
+ const bearer = ${JSON.stringify(B)};
21
21
  const headers = {
22
22
  'Authorization': 'Bearer ' + decodeURIComponent(bearer),
23
23
  'X-Csrf-Token': ct0,
@@ -45,23 +45,54 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
45
45
  withArticlePlainText: true,
46
46
  });
47
47
 
48
- const url = '/i/api/graphql/' + ${JSON.stringify(X)} + '/TweetResultByRestId?variables='
48
+ const url = '/i/api/graphql/' + ${JSON.stringify(L)} + '/TweetResultByRestId?variables='
49
49
  + encodeURIComponent(variables)
50
50
  + '&features=' + encodeURIComponent(features)
51
51
  + '&fieldToggles=' + encodeURIComponent(fieldToggles);
52
52
 
53
- const resp = await fetch(url, {headers, credentials: 'include'});
53
+ let resp;
54
+ try {
55
+ resp = await fetch(url, {headers, credentials: 'include'});
56
+ } catch (error) {
57
+ return {error: 'Twitter article request failed: ' + String(error && error.message || error)};
58
+ }
54
59
  if (!resp.ok) return {httpStatus: resp.status};
55
- const d = await resp.json();
60
+ let d;
61
+ try {
62
+ d = await resp.json();
63
+ } catch {
64
+ return {error: 'Twitter API response was not valid JSON', hint: 'You may be logged out or the request was blocked'};
65
+ }
66
+ if (!d || typeof d !== 'object' || Array.isArray(d)) {
67
+ return {error: 'Twitter API response payload was malformed'};
68
+ }
56
69
 
57
- const result = d.data?.tweetResult?.result;
58
- if (!result) return {error: 'Article not found'};
70
+ const result = d?.data?.tweetResult?.result;
71
+ if (!result) {
72
+ if (Array.isArray(d.errors) && d.errors.length > 0) {
73
+ return {error: 'Twitter TweetResultByRestId returned GraphQL errors: ' + JSON.stringify(d.errors).slice(0, 200)};
74
+ }
75
+ return {error: 'Article not found'};
76
+ }
59
77
 
60
78
  // Unwrap TweetWithVisibilityResults
79
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
80
+ return {error: 'Twitter API response tweet result was malformed'};
81
+ }
61
82
  const tw = result.tweet || result;
83
+ if (!tw || typeof tw !== 'object' || Array.isArray(tw)) {
84
+ return {error: 'Twitter API response tweet result was malformed'};
85
+ }
62
86
  const legacy = tw.legacy || {};
63
87
  const user = tw.core?.user_results?.result;
64
- const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
88
+ const returnedTweetId = tw.rest_id || legacy.id_str;
89
+ if (typeof returnedTweetId !== 'string' || returnedTweetId !== tweetId) {
90
+ return {error: 'Twitter API response did not match requested tweet ' + tweetId};
91
+ }
92
+ const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
93
+ if (typeof screenName !== 'string' || !/^[A-Za-z0-9_]{1,15}$/.test(screenName)) {
94
+ return {error: 'Twitter API response did not include a valid author screen name for tweet ' + tweetId};
95
+ }
65
96
 
66
97
  // Extract article content
67
98
  const articleResults = tw.article?.article_results?.result;
@@ -78,15 +109,25 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
78
109
  }
79
110
  return {error: 'Tweet ' + tweetId + ' has no article content'};
80
111
  }
112
+ if (!articleResults || typeof articleResults !== 'object' || Array.isArray(articleResults)) {
113
+ return {error: 'Twitter API response article result was malformed'};
114
+ }
81
115
 
82
116
  const title = articleResults.title || '(Untitled)';
83
117
  const contentState = articleResults.content_state || {};
118
+ if (!contentState || typeof contentState !== 'object' || Array.isArray(contentState)) {
119
+ return {error: 'Twitter API response article content was malformed'};
120
+ }
84
121
  const blocks = contentState.blocks || [];
122
+ if (!Array.isArray(blocks)) {
123
+ return {error: 'Twitter API response article blocks were malformed'};
124
+ }
85
125
 
86
126
  // Convert draft.js blocks to Markdown
87
127
  const parts = [];
88
128
  let orderedCounter = 0;
89
129
  for (const block of blocks) {
130
+ if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
90
131
  const blockType = block.type || 'unstyled';
91
132
  if (blockType === 'atomic') continue;
92
133
  const text = block.text || '';
@@ -113,4 +154,4 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
113
154
  url: 'https://x.com/' + screenName + '/status/' + tweetId,
114
155
  }];
115
156
  }
116
- `);if(z?.httpStatus)throw new G(L("TweetResultByRestId",z.httpStatus));if(z?.error)throw new G(z.error+(z.hint?` (${z.hint})`:""));return z||[]}});
157
+ `));if(!Array.isArray(z)&&!M(z))throw new H("Twitter article response payload is malformed");if(z?.httpStatus){const G=k("TweetResultByRestId",z.httpStatus);if(z.httpStatus===401||z.httpStatus===403)throw new Z("x.com",G);throw new H(G)}if(z?.error)throw new H(z.error+(z.hint?` (${z.hint})`:""));if(!Array.isArray(z))throw new H("Twitter article response payload is malformed");return z}});
@@ -3,7 +3,7 @@ import{ArgumentError as k,AuthRequiredError as z,CommandExecutionError as M,Empt
3
3
  return link ? link.getAttribute('href') : null;
4
4
  }`));if(!D||typeof D!=="string")throw new z("x.com","Could not detect logged-in user. Are you logged in?");Z=Q(D);if(!Z)throw new z("x.com","Could not detect logged-in user. Are you logged in?")}await G.goto(`https://x.com/${Z}`);await G.wait(3);const $=(await G.getCookies({url:"https://x.com"})).find((D)=>D.name==="ct0")?.value||null;if(!$)throw new z("x.com","Not logged into x.com (no ct0 cookie)");const L=await b(G,"UserByScreenName",S),X=Y(await G.evaluate(`
5
5
  async () => {
6
- const screenName = "${Z}";
6
+ const screenName = ${JSON.stringify(Z)};
7
7
  const ct0 = ${JSON.stringify($)};
8
8
 
9
9
  const bearer = ${JSON.stringify(x)};
@@ -1,7 +1,8 @@
1
- import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequiredError as W,CliError as F,EmptyResultError as N}from"@jackwener/opencli/errors";import{parseNoteId as O,buildNoteUrl as U}from"./note-helpers.js";const j='.author-wrapper a[href*="/user/profile/"], a.name[href*="/user/profile/"], a.user-name[href*="/user/profile/"], a[href*="/user/profile/"]';export function parseXhsProfileHref(z,B="www.xiaohongshu.com"){const D=typeof z==="string"?z.trim():"";if(!D)return"";const M=String(B||"www.xiaohongshu.com").toLowerCase();let K;try{K=new URL(D,`https://${M}`)}catch{return""}if(K.protocol!=="https:")return"";if(K.hostname.toLowerCase()!==M)return"";return K.pathname.match(/^\/user\/profile\/([a-zA-Z0-9]+)\/?$/)?.[1]??""}export function buildXhsProfileUrl(z,B="www.xiaohongshu.com"){const D=parseXhsProfileHref(z,B);if(!D)return"";return`https://${B}/user/profile/${D}`}export function parseCommentLimit(z,B=20){const D=Number(z);if(!Number.isFinite(D))return B;return Math.max(1,Math.min(Math.floor(D),50))}export function parseXhsLikeCountText(z){const B=/^(?:\d+|\d{1,3}(?:[,,]\d{3})+)\+?$/u,D=/^((?:\d+|\d{1,3}(?:[,,]\d{3})+)(?:\.\d+)?)([wWkK万千])\+?$/u,M=String(z??"").replace(/\s+/g,"");if(!M)return 0;if(B.test(M))return Number(M.replace(/[,+,]/g,""));const K=M.match(D);if(!K)return 0;const V=Number(K[1].replace(/[,,]/g,""));if(!Number.isFinite(V))return 0;const Q=K[2].toLowerCase();return Math.round(V*(Q==="w"||Q==="万"?1e4:1000))}export function buildCommentsExtractJs(z){const B=parseXhsLikeCountText.toString();return`
1
+ import{cli as F,Strategy as U}from"@jackwener/opencli/registry";import{AuthRequiredError as j,CliError as T,CommandExecutionError as Z,EmptyResultError as _}from"@jackwener/opencli/errors";import{parseNoteId as y,buildNoteUrl as P}from"./note-helpers.js";const L='.author-wrapper a[href*="/user/profile/"], a.name[href*="/user/profile/"], a.user-name[href*="/user/profile/"], a[href*="/user/profile/"]';export function parseXhsProfileHref(G,D="www.xiaohongshu.com"){const B=typeof G==="string"?G.trim():"";if(!B)return"";const Q=String(D||"www.xiaohongshu.com").toLowerCase();let K;try{K=new URL(B,`https://${Q}`)}catch{return""}if(K.protocol!=="https:")return"";if(K.hostname.toLowerCase()!==Q)return"";return K.pathname.match(/^\/user\/profile\/([a-zA-Z0-9]+)\/?$/)?.[1]??""}export function buildXhsProfileUrl(G,D="www.xiaohongshu.com"){const B=parseXhsProfileHref(G,D);if(!B)return"";return`https://${D}/user/profile/${B}`}export function parseCommentLimit(G,D=20){const B=Number(G);if(!Number.isFinite(B))return D;return Math.max(1,Math.min(Math.floor(B),50))}export function parseXhsLikeCountText(G){const D=/^(?:\d+|\d{1,3}(?:[,,]\d{3})+)\+?$/u,B=/^((?:\d+|\d{1,3}(?:[,,]\d{3})+)(?:\.\d+)?)([wWkK万千])\+?$/u,Q=String(G??"").replace(/\s+/g,"");if(!Q)return 0;if(D.test(Q))return Number(Q.replace(/[,+,]/g,""));const K=Q.match(B);if(!K)return 0;const M=Number(K[1].replace(/[,,]/g,""));if(!Number.isFinite(M))return 0;const Y=K[2].toLowerCase();return Math.round(M*(Y==="w"||Y==="万"?1e4:1000))}function $(G,D,B){if(G==null)return"";if(typeof G!=="string")throw new Z(`${B}: malformed comment row ${D}`);return G}export function normalizeCommentImages(G,D){if(G==null)return[];if(!Array.isArray(G))throw new Z(`${D}: malformed comment row images`);const B=[];for(const Q of G){if(typeof Q!=="string")throw new Z(`${D}: malformed comment row image URL`);const K=Q.trim();let M;try{M=new URL(K)}catch{throw new Z(`${D}: malformed comment row image URL`)}if(M.protocol!=="https:"&&M.protocol!=="http:"||M.username||M.password)throw new Z(`${D}: malformed comment row image URL`);const Y=M.toString();if(!B.includes(Y))B.push(Y)}return B}export function normalizeCommentRows(G,D="xiaohongshu/comments"){if(G==null)return[];if(!Array.isArray(G))throw new Z(`${D}: malformed comments payload`);return G.map((B,Q)=>{if(!B||typeof B!=="object"||Array.isArray(B))throw new Z(`${D}: malformed comment row at index ${Q}`);const K=$(B.text,"text",D);if(!K)throw new Z(`${D}: malformed comment row text`);const M=Number(B.likes);if(!Number.isInteger(M)||M<0)throw new Z(`${D}: malformed comment row likes`);if(typeof B.is_reply!=="boolean")throw new Z(`${D}: malformed comment row is_reply`);return{author:$(B.author,"author",D),authorHrefRaw:$(B.authorHrefRaw,"authorHrefRaw",D),text:K,likes:M,time:$(B.time,"time",D),is_reply:B.is_reply,reply_to:$(B.reply_to,"reply_to",D),images:normalizeCommentImages(B.images,D)}})}export function buildCommentsExtractJs(G,D=20){const B=parseXhsLikeCountText.toString();return`
2
2
  (async () => {
3
3
  const wait = (ms) => new Promise(r => setTimeout(r, ms))
4
- const withReplies = ${z}
4
+ const withReplies = ${G}
5
+ const targetCount = ${Number(D)||20}
5
6
 
6
7
  // Check login state
7
8
  const bodyText = document.body?.innerText || ''
@@ -9,15 +10,40 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
9
10
  const securityBlock = /安全限制|访问链接异常/.test(bodyText)
10
11
  || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
11
12
 
12
- // Scroll the note container to trigger comment loading
13
+ // Scroll to trigger comment loading. Xiaohongshu loads comments in
14
+ // small async batches via IntersectionObserver, and depending on the
15
+ // page layout / viewport the actual scrollable ancestor can be
16
+ // .note-scroller, .container, or the document itself — so each round
17
+ // drives all of them plus scrollIntoView on the last loaded comment,
18
+ // which works regardless of which element actually owns the scrollbar.
19
+ // A single stalled round doesn't mean the list is exhausted — keep
20
+ // going until growth stalls for several consecutive rounds, we've
21
+ // loaded enough top-level comments to satisfy --limit, or we hit the
22
+ // hard round cap.
13
23
  const scroller = document.querySelector('.note-scroller') || document.querySelector('.container')
14
- if (scroller) {
15
- for (let i = 0; i < 3; i++) {
16
- const beforeCount = scroller.querySelectorAll('.parent-comment').length
17
- scroller.scrollTo(0, scroller.scrollHeight)
18
- await wait(800 + Math.random() * 1200)
19
- const afterCount = scroller.querySelectorAll('.parent-comment').length
20
- if (afterCount <= beforeCount) break
24
+ const driveScroll = () => {
25
+ if (scroller) scroller.scrollTo(0, scroller.scrollHeight)
26
+ const comments = document.querySelectorAll('.parent-comment')
27
+ const last = comments[comments.length - 1]
28
+ if (last && typeof last.scrollIntoView === 'function') last.scrollIntoView({ block: 'end' })
29
+ if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
30
+ window.scrollTo(0, document.body.scrollHeight)
31
+ }
32
+ }
33
+ {
34
+ let stall = 0
35
+ for (let i = 0; i < 60; i++) {
36
+ const beforeCount = document.querySelectorAll('.parent-comment').length
37
+ if (beforeCount >= targetCount) break
38
+ driveScroll()
39
+ await wait(1000 + Math.random() * 1200)
40
+ const afterCount = document.querySelectorAll('.parent-comment').length
41
+ if (afterCount <= beforeCount) {
42
+ stall++
43
+ if (stall >= 6) break
44
+ } else {
45
+ stall = 0
46
+ }
21
47
  }
22
48
  }
23
49
 
@@ -26,12 +52,27 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
26
52
  const parseLikes = (el) => {
27
53
  return parseLikeCountText(clean(el))
28
54
  }
29
- const HREF_SELECTOR = ${JSON.stringify(j)}
55
+ const HREF_SELECTOR = ${JSON.stringify(L)}
30
56
  const extractAuthorHref = (el) => {
31
57
  if (!el) return ''
32
58
  const anchor = el.querySelector(HREF_SELECTOR)
33
59
  return anchor ? (anchor.getAttribute('href') || '') : ''
34
60
  }
61
+ // Attached comment photos, excluding avatars, inline emoji, badges, and
62
+ // other UI images. Only images inside comment/reply media containers are
63
+ // projected as media evidence.
64
+ const extractImages = (el) => {
65
+ if (!el) return []
66
+ const urls = []
67
+ el.querySelectorAll('img').forEach(img => {
68
+ if (img.classList.contains('avatar-item')) return
69
+ if (img.closest('.content, .note-text')) return
70
+ if (!img.closest('.comment-pic, .reply-pic, .comment-image, .reply-image, .comment-img, .reply-img, [class*="comment-pic"], [class*="reply-pic"], [class*="comment-image"], [class*="reply-image"]')) return
71
+ const src = img.currentSrc || img.src || img.getAttribute('data-src') || ''
72
+ if (src && !urls.includes(src)) urls.push(src)
73
+ })
74
+ return urls
75
+ }
35
76
  const expandReplyThreads = async (root) => {
36
77
  if (!withReplies || !root) return
37
78
  const clickedTexts = new Set()
@@ -65,9 +106,10 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
65
106
  const text = clean(item.querySelector('.content, .note-text'))
66
107
  const likes = parseLikes(item.querySelector('.count'))
67
108
  const time = clean(item.querySelector('.date, .time'))
109
+ const images = extractImages(item)
68
110
 
69
111
  if (!text) continue
70
- results.push({ author, authorHrefRaw, text, likes, time, is_reply: false, reply_to: '' })
112
+ results.push({ author, authorHrefRaw, text, likes, time, is_reply: false, reply_to: '', images })
71
113
 
72
114
  // Extract nested replies (楼中楼)
73
115
  if (withReplies) {
@@ -78,12 +120,13 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
78
120
  const sText = clean(sub.querySelector('.content, .note-text'))
79
121
  const sLikes = parseLikes(sub.querySelector('.count'))
80
122
  const sTime = clean(sub.querySelector('.date, .time'))
123
+ const sImages = extractImages(sub)
81
124
  if (!sText) return
82
- results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author })
125
+ results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author, images: sImages })
83
126
  })
84
127
  }
85
128
  }
86
129
 
87
130
  return { pageUrl: location.href, securityBlock, loginWall, results }
88
131
  })()
89
- `}export const command=A({site:"xiaohongshu",name:"comments",access:"read",description:"获取小红书笔记评论(支持楼中楼子回复)",domain:"www.xiaohongshu.com",strategy:J.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full Xiaohongshu note URL with xsec_token"},{name:"limit",type:"int",default:20,help:"Number of top-level comments (max 50)"},{name:"with-replies",type:"boolean",default:!1,help:"Include nested replies (楼中楼)"}],columns:["rank","author","userId","profileUrl","text","likes","time","is_reply","reply_to"],func:async(z,B)=>{const D=parseCommentLimit(B.limit),M=Boolean(B["with-replies"]),K=String(B["note-id"]),V=O(K);await z.goto(U(K,{commandName:"xiaohongshu comments"}));await z.wait({time:2+Math.random()*3});const Q=await z.evaluate(buildCommentsExtractJs(M));if(!Q||typeof Q!=="object")throw new N("xiaohongshu/comments","Unexpected evaluate response");if(Q.securityBlock)throw new F("SECURITY_BLOCK","Xiaohongshu security block: the note detail page was blocked by risk control.",/^https?:\/\//.test(K)?"The page may be temporarily restricted. Try again later or from a different session.":"Try using a full URL from search results (with xsec_token) instead of a bare note ID.");if(Q.loginWall)throw new W("www.xiaohongshu.com","Note comments require login");const Z=Q.results??[],$=(G,Y)=>({rank:Y+1,author:G.author,userId:G.authorHrefRaw?parseXhsProfileHref(G.authorHrefRaw):"",profileUrl:G.authorHrefRaw?buildXhsProfileUrl(G.authorHrefRaw):"",text:G.text,likes:G.likes,time:G.time,is_reply:G.is_reply,reply_to:G.reply_to});if(M){const G=[];let Y=0;for(const q of Z){if(!q.is_reply)Y++;if(Y>D)break;G.push(q)}return G.map($)}return Z.slice(0,D).map($)}});
132
+ `}export const command=F({site:"xiaohongshu",name:"comments",access:"read",description:"获取小红书笔记评论(支持楼中楼子回复)",domain:"www.xiaohongshu.com",strategy:U.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full Xiaohongshu note URL with xsec_token"},{name:"limit",type:"int",default:20,help:"Number of top-level comments (max 50)"},{name:"with-replies",type:"boolean",default:!1,help:"Include nested replies (楼中楼)"}],columns:["rank","author","userId","profileUrl","text","likes","time","is_reply","reply_to","images"],func:async(G,D)=>{const B=parseCommentLimit(D.limit),Q=Boolean(D["with-replies"]),K=String(D["note-id"]),M=y(K);await G.goto(P(K,{commandName:"xiaohongshu comments"}));await G.wait({time:2+Math.random()*3});const Y=await G.evaluate(buildCommentsExtractJs(Q,B));if(!Y||typeof Y!=="object")throw new _("xiaohongshu/comments","Unexpected evaluate response");if(Y.securityBlock)throw new T("SECURITY_BLOCK","Xiaohongshu security block: the note detail page was blocked by risk control.",/^https?:\/\//.test(K)?"The page may be temporarily restricted. Try again later or from a different session.":"Try using a full URL from search results (with xsec_token) instead of a bare note ID.");if(Y.loginWall)throw new j("www.xiaohongshu.com","Note comments require login");const A=normalizeCommentRows(Y.results,"xiaohongshu/comments"),J=(V,q)=>({rank:q+1,author:V.author,userId:V.authorHrefRaw?parseXhsProfileHref(V.authorHrefRaw):"",profileUrl:V.authorHrefRaw?buildXhsProfileUrl(V.authorHrefRaw):"",text:V.text,likes:V.likes,time:V.time,is_reply:V.is_reply,reply_to:V.reply_to,images:V.images??[]});if(Q){const V=[];let q=0;for(const W of A){if(!W.is_reply)q++;if(q>B)break;V.push(W)}return V.map(J)}return A.slice(0,B).map(J)}});
@@ -0,0 +1,14 @@
1
+ function E(q,D){return q+Math.random()*(D-q)}function V(q,D=0.4,B=0.02){const A=q*D;return Math.max(B,q-A+Math.random()*A*2)}export async function humanWait(q,D,B=0.4){try{await q.wait({time:V(D,B)})}catch{}}export async function humanType(q,D){const B=String(D);if(!B)return;if(typeof q.insertText!=="function"){try{await q.insertText?.(B)}catch{}return}let A=0;while(A<B.length){const H=Math.max(1,Math.round(E(1,4))),I=B.slice(A,A+H);A+=I.length;try{await q.insertText(I)}catch{try{await q.insertText(B.slice(A-I.length))}catch{}return}const K=Math.random()<0.12?E(0.35,0.8):E(0.04,0.18);try{await q.wait({time:K})}catch{}}}function Y(q){if(!q.__xhsCursor)q.__xhsCursor={x:Math.round(E(80,520)),y:Math.round(E(120,420))};return q.__xhsCursor}function Z(q,D,B){const A=D.x-q.x,H=D.y-q.y,I=Math.hypot(A,H)||1,K=-H/I,N=A/I,J=E(0.05,0.22)*I*(Math.random()<0.5?1:-1),Q={x:q.x+A*E(0.2,0.4)+K*J,y:q.y+H*E(0.2,0.4)+N*J},R={x:q.x+A*E(0.6,0.8)+K*J*E(0.3,0.7),y:q.y+H*E(0.6,0.8)+N*J*E(0.3,0.7)},U=[];for(let O=1;O<=B;O++){const F=O/B,G=1-F,W=G*G*G*q.x+3*G*G*F*Q.x+3*G*F*F*R.x+F*F*F*D.x,X=G*G*G*q.y+3*G*G*F*Q.y+3*G*F*F*R.y+F*F*F*D.y;U.push({x:Math.round(W),y:Math.round(X)})}return U}export async function humanMoveTo(q,D,B){if(typeof q.cdp!=="function")return;const A={x:Math.round(D+E(-3,3)),y:Math.round(B+E(-3,3))},H=Y(q),I=Math.hypot(A.x-H.x,A.y-H.y),K=Math.max(6,Math.min(18,Math.round(I/30))),N=Z(H,A,K);for(const J of N){try{await q.cdp("Input.dispatchMouseEvent",{type:"mouseMoved",x:J.x,y:J.y})}catch{return}try{await q.wait({time:V(0.02,0.6,0.008)})}catch{}}q.__xhsCursor=A}export async function elementViewportCenter(q,D){try{const B=await q.evaluate(`
2
+ (sels => {
3
+ const __opencli_xhs_el_center = true;
4
+ for (const sel of sels) {
5
+ for (const el of document.querySelectorAll(sel)) {
6
+ if (!el || el.offsetParent === null) continue;
7
+ const r = el.getBoundingClientRect();
8
+ if (r.width <= 0 || r.height <= 0) continue;
9
+ return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
10
+ }
11
+ }
12
+ return null;
13
+ })(${JSON.stringify(D)})
14
+ `),A=B&&typeof B==="object"&&"data"in B?B.data:B;if(A&&typeof A.x==="number"&&typeof A.y==="number")return A}catch{}return null}