publishport-opencli 1.8.5-pp.33 → 1.8.5-pp.35

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
@@ -11864,6 +11864,92 @@
11864
11864
  "sourceFile": "discord-app/threads.js",
11865
11865
  "navigateBefore": true
11866
11866
  },
11867
+ {
11868
+ "site": "discord-web",
11869
+ "name": "login",
11870
+ "description": "Open discord-web login and wait until the browser session is authenticated",
11871
+ "access": "write",
11872
+ "domain": "discord.com",
11873
+ "strategy": "cookie",
11874
+ "browser": true,
11875
+ "args": [
11876
+ {
11877
+ "name": "timeout",
11878
+ "type": "int",
11879
+ "default": 300,
11880
+ "required": false,
11881
+ "help": "Maximum seconds to wait for the user to finish login"
11882
+ }
11883
+ ],
11884
+ "columns": [
11885
+ "status",
11886
+ "logged_in",
11887
+ "site",
11888
+ "username",
11889
+ "display_name"
11890
+ ],
11891
+ "type": "js",
11892
+ "modulePath": "discord-web/auth.js",
11893
+ "sourceFile": "discord-web/auth.js",
11894
+ "navigateBefore": false,
11895
+ "siteSession": "persistent",
11896
+ "defaultWindowMode": "foreground"
11897
+ },
11898
+ {
11899
+ "site": "discord-web",
11900
+ "name": "send",
11901
+ "description": "Send a message to a Discord channel (discord.com). Pass the channel URL or \"<guild>/<channel>\". Multi-line text is sent as one message.",
11902
+ "access": "write",
11903
+ "domain": "discord.com",
11904
+ "strategy": "cookie",
11905
+ "browser": true,
11906
+ "args": [
11907
+ {
11908
+ "name": "channel",
11909
+ "type": "string",
11910
+ "required": true,
11911
+ "positional": true,
11912
+ "help": "Channel URL (https://discord.com/channels/<guild>/<channel>) or \"<guild>/<channel>\""
11913
+ },
11914
+ {
11915
+ "name": "text",
11916
+ "type": "string",
11917
+ "required": true,
11918
+ "positional": true,
11919
+ "help": "Message text (multi-line allowed)"
11920
+ }
11921
+ ],
11922
+ "columns": [
11923
+ "status",
11924
+ "channel_id",
11925
+ "message"
11926
+ ],
11927
+ "type": "js",
11928
+ "modulePath": "discord-web/send.js",
11929
+ "sourceFile": "discord-web/send.js",
11930
+ "navigateBefore": "https://discord.com"
11931
+ },
11932
+ {
11933
+ "site": "discord-web",
11934
+ "name": "whoami",
11935
+ "description": "Show the current logged-in discord-web account",
11936
+ "access": "read",
11937
+ "domain": "discord.com",
11938
+ "strategy": "cookie",
11939
+ "browser": true,
11940
+ "args": [],
11941
+ "columns": [
11942
+ "logged_in",
11943
+ "site",
11944
+ "username",
11945
+ "display_name"
11946
+ ],
11947
+ "type": "js",
11948
+ "modulePath": "discord-web/auth.js",
11949
+ "sourceFile": "discord-web/auth.js",
11950
+ "navigateBefore": false,
11951
+ "siteSession": "persistent"
11952
+ },
11867
11953
  {
11868
11954
  "site": "dockerhub",
11869
11955
  "name": "image",
@@ -17272,6 +17358,37 @@
17272
17358
  "modulePath": "hackernews/jobs.js",
17273
17359
  "sourceFile": "hackernews/jobs.js"
17274
17360
  },
17361
+ {
17362
+ "site": "hackernews",
17363
+ "name": "login",
17364
+ "description": "Open hackernews login and wait until the browser session is authenticated",
17365
+ "access": "write",
17366
+ "domain": "news.ycombinator.com",
17367
+ "strategy": "cookie",
17368
+ "browser": true,
17369
+ "args": [
17370
+ {
17371
+ "name": "timeout",
17372
+ "type": "int",
17373
+ "default": 300,
17374
+ "required": false,
17375
+ "help": "Maximum seconds to wait for the user to finish login"
17376
+ }
17377
+ ],
17378
+ "columns": [
17379
+ "status",
17380
+ "logged_in",
17381
+ "site",
17382
+ "username",
17383
+ "karma"
17384
+ ],
17385
+ "type": "js",
17386
+ "modulePath": "hackernews/auth.js",
17387
+ "sourceFile": "hackernews/auth.js",
17388
+ "navigateBefore": false,
17389
+ "siteSession": "persistent",
17390
+ "defaultWindowMode": "foreground"
17391
+ },
17275
17392
  {
17276
17393
  "site": "hackernews",
17277
17394
  "name": "new",
@@ -17492,6 +17609,27 @@
17492
17609
  "modulePath": "hackernews/user.js",
17493
17610
  "sourceFile": "hackernews/user.js"
17494
17611
  },
17612
+ {
17613
+ "site": "hackernews",
17614
+ "name": "whoami",
17615
+ "description": "Show the current logged-in hackernews account",
17616
+ "access": "read",
17617
+ "domain": "news.ycombinator.com",
17618
+ "strategy": "cookie",
17619
+ "browser": true,
17620
+ "args": [],
17621
+ "columns": [
17622
+ "logged_in",
17623
+ "site",
17624
+ "username",
17625
+ "karma"
17626
+ ],
17627
+ "type": "js",
17628
+ "modulePath": "hackernews/auth.js",
17629
+ "sourceFile": "hackernews/auth.js",
17630
+ "navigateBefore": false,
17631
+ "siteSession": "persistent"
17632
+ },
17495
17633
  {
17496
17634
  "site": "hashnode",
17497
17635
  "name": "login",
@@ -32186,6 +32324,86 @@
32186
32324
  "sourceFile": "reddit/search.js",
32187
32325
  "navigateBefore": "https://reddit.com"
32188
32326
  },
32327
+ {
32328
+ "site": "reddit",
32329
+ "name": "submit",
32330
+ "description": "Submit a new post to a subreddit. Text post by default (--text); pass --url for a link post. Note: many subreddits gate submissions behind karma/age requirements or flair — a rejection is surfaced verbatim.",
32331
+ "access": "write",
32332
+ "domain": "reddit.com",
32333
+ "strategy": "cookie",
32334
+ "browser": true,
32335
+ "args": [
32336
+ {
32337
+ "name": "subreddit",
32338
+ "type": "string",
32339
+ "required": true,
32340
+ "positional": true,
32341
+ "help": "Target subreddit, e.g. \"test\" or \"r/test\""
32342
+ },
32343
+ {
32344
+ "name": "title",
32345
+ "type": "string",
32346
+ "required": true,
32347
+ "positional": true,
32348
+ "help": "Post title (max 300 chars)"
32349
+ },
32350
+ {
32351
+ "name": "text",
32352
+ "type": "string",
32353
+ "required": false,
32354
+ "help": "Body text (markdown) for a self/text post"
32355
+ },
32356
+ {
32357
+ "name": "url",
32358
+ "type": "string",
32359
+ "required": false,
32360
+ "help": "External URL for a link post (mutually exclusive with --text)"
32361
+ },
32362
+ {
32363
+ "name": "flair-id",
32364
+ "type": "string",
32365
+ "required": false,
32366
+ "help": "Flair template id, if the subreddit requires one"
32367
+ },
32368
+ {
32369
+ "name": "flair-text",
32370
+ "type": "string",
32371
+ "required": false,
32372
+ "help": "Flair text to accompany --flair-id"
32373
+ },
32374
+ {
32375
+ "name": "nsfw",
32376
+ "type": "bool",
32377
+ "default": false,
32378
+ "required": false,
32379
+ "help": "Mark the post as NSFW"
32380
+ },
32381
+ {
32382
+ "name": "spoiler",
32383
+ "type": "bool",
32384
+ "default": false,
32385
+ "required": false,
32386
+ "help": "Mark the post as a spoiler"
32387
+ },
32388
+ {
32389
+ "name": "sendreplies",
32390
+ "type": "bool",
32391
+ "default": true,
32392
+ "required": false,
32393
+ "help": "Receive inbox replies for this post (default: true)"
32394
+ }
32395
+ ],
32396
+ "columns": [
32397
+ "status",
32398
+ "url",
32399
+ "id",
32400
+ "message"
32401
+ ],
32402
+ "type": "js",
32403
+ "modulePath": "reddit/submit.js",
32404
+ "sourceFile": "reddit/submit.js",
32405
+ "navigateBefore": "https://reddit.com"
32406
+ },
32189
32407
  {
32190
32408
  "site": "reddit",
32191
32409
  "name": "subreddit",
@@ -684,6 +684,8 @@ var PP = (function () {
684
684
  // uploadFn: async (src) => ({ url, attrs? }) | url。用于装不进声明式 spec 的平台
685
685
  // (如掘金 ImageX 多步 + AWS4 签名):平台自己实现「拿到 src → 返回新 URL」即可。
686
686
  // 同样统一处理 HTML <img> + Markdown ![](),去重 / skip / 失败兜底。
687
+ // opts.allowRelative:允许 uploadFn 返回站内相对路径(如思否 /img/<id>——其
688
+ // cover 字段和自家编辑器正文都只用这种路径,完整 URL 反而被拒)。
687
689
  async function processImagesWith(content, uploadFn, opts) {
688
690
  opts = opts || {};
689
691
  var skip = opts.skip || [];
@@ -706,7 +708,8 @@ var PP = (function () {
706
708
  var res = await uploadFn(src);
707
709
  var url = (res && typeof res === 'object') ? res.url : res;
708
710
  if (!url) throw new Error('uploadFn returned no url');
709
- if (!isHttpUrl(url)) throw new Error('uploadFn 返回的不是合法 URL: ' + String(url).slice(0, 150));
711
+ var isRel = String(url).charAt(0) === '/' && String(url).charAt(1) !== '/';
712
+ if (!isHttpUrl(url) && !(opts.allowRelative && isRel)) throw new Error('uploadFn 返回的不是合法 URL: ' + String(url).slice(0, 150));
710
713
  cache[src] = { url: url, attrs: (res && res.attrs) || null };
711
714
  report.uploaded.push({ src: src.slice(0, 120), url: url });
712
715
  } catch (e) {
@@ -11,12 +11,12 @@ const __publish = (`+Q+`);
11
11
  `:"")+`const __content0 = I.content;
12
12
  let content = I.content;
13
13
  if (I.outputFormat === "html" && I.preprocessConfig) content = PP.preprocess(content, I.preprocessConfig);
14
- `+(X?`const __t = await PP.processImagesWith(content, (src) => __upload(src, PP), { skip: I.imageSkip });
14
+ `+(X?`const __t = await PP.processImagesWith(content, (src) => __upload(src, PP), { skip: I.imageSkip, allowRelative: I.imageAllowRelative });
15
15
  `:`const __t = await PP.transferImages(content, I.imageSpec, I.imageSkip);
16
16
  `)+`content = __t.content;
17
17
  if (I.publishParams && typeof I.publishParams.cover === "string" && I.publishParams.cover) {
18
18
  var __cvWrap = '<img src="' + I.publishParams.cover + '">';
19
- `+(X?` var __cv = await PP.processImagesWith(__cvWrap, (src) => __upload(src, PP), { skip: I.imageSkip });
19
+ `+(X?` var __cv = await PP.processImagesWith(__cvWrap, (src) => __upload(src, PP), { skip: I.imageSkip, allowRelative: I.imageAllowRelative });
20
20
  `:` var __cv = await PP.transferImages(__cvWrap, I.imageSpec, I.imageSkip);
21
21
  `)+` if (__cv.failed.length) {
22
22
  `+` return { ok: false, stage: "cover", message: "封面图转存失败:" + __cv.failed[0].error, uploaded: __t.uploaded, failed: __t.failed.concat(__cv.failed) };
@@ -31,4 +31,4 @@ if (!__pub || __pub.ok === false) {
31
31
  return { ok: false, stage: (__pub && __pub.stage) || "publish", status: __pub && __pub.status, message: (__pub && __pub.message) || "publish failed", detail: __pub && __pub.detail, uploaded: __upN, failed: __failN };
32
32
  }
33
33
  return { ok: true, id: String(__pub.id == null ? "" : __pub.id), url: __pub.url || "", draft: !!__pub.draft, uploaded: __upN, failed: __failN };
34
- })()`}export async function publishArticle(K,Q){const{title:X,body:Z,format:G="auto",draftOnly:D=!1,profile:q,publishParams:k=null}=Q;if(!q)throw Error("publishArticle: profile is required");if(!q.home)throw Error("publishArticle: profile.home is required");if(typeof q.publish!=="function")throw Error("publishArticle: profile.publish must be a function");C(Z,{fileFlag:"--file"});const W=H(Z,{format:G}),w=q.outputFormat==="markdown"||q.needsMarkdown===!0,L=q.outputFormat==="html"||q.needsHtml===!0,_=w?await U(W.markdown):{content:null,missing:[]},z=L?await U(W.html):{content:null,missing:[]},A=_.content,J=z.content,y=q.outputFormat==="markdown"?A:J,N=[],T=new Set;for(const V of[..._.missing,...z.missing]){if(T.has(V.src))continue;T.add(V.src);N.push(V)}let Y=k;if(Y&&typeof Y.cover==="string"&&Y.cover){const V=Y.cover.trim();if(/["<>\s]/.test(V))throw new O("封面图路径/URL 含非法字符(引号/尖括号/空白):"+V.slice(0,120));if(I(V))try{const{dataUri:$}=await E(V);Y={...Y,cover:$}}catch($){throw new O("封面图读取失败:"+V+"("+String($&&$.message||$)+")")}else Y={...Y,cover:V}}await gotoWritePage(K,q.home,q.originRe);const R={title:X,content:y,markdown:q.needsMarkdown===!0&&q.outputFormat!=="markdown"?A:null,html:q.needsHtml===!0&&q.outputFormat!=="html"?J:null,draftOnly:!!D,outputFormat:q.outputFormat,preprocessConfig:q.preprocessConfig||null,imageSpec:q.image?.spec||null,imageSkip:q.image?.skip||[],publishParams:Y},j=typeof q.image?.uploadFn==="function"?q.image.uploadFn.toString():null,x=buildPublishJs(R,q.publish.toString(),j),B=await K.evaluate(x);if(!B||B.ok===!1){const V=B?.stage||"publish",$=B?.status!=null?` (HTTP ${B.status})`:"",M=B?.detail!=null?`;接口原始响应:${typeof B.detail==="string"?B.detail:JSON.stringify(B.detail)}`:"";throw new O(`[${V}] ${B?.message||"发布失败"}${$}${M}`)}return{id:B.id,url:B.url,draft:B.draft,images:{uploaded:B.uploaded||[],failed:(B.failed||[]).concat(N)}}}export const __test__={selectContent,originReFromHome,buildPublishJs,publishArticle};
34
+ })()`}export async function publishArticle(K,Q){const{title:X,body:Z,format:G="auto",draftOnly:D=!1,profile:q,publishParams:k=null}=Q;if(!q)throw Error("publishArticle: profile is required");if(!q.home)throw Error("publishArticle: profile.home is required");if(typeof q.publish!=="function")throw Error("publishArticle: profile.publish must be a function");C(Z,{fileFlag:"--file"});const W=H(Z,{format:G}),w=q.outputFormat==="markdown"||q.needsMarkdown===!0,L=q.outputFormat==="html"||q.needsHtml===!0,_=w?await U(W.markdown):{content:null,missing:[]},z=L?await U(W.html):{content:null,missing:[]},A=_.content,J=z.content,y=q.outputFormat==="markdown"?A:J,N=[],T=new Set;for(const V of[..._.missing,...z.missing]){if(T.has(V.src))continue;T.add(V.src);N.push(V)}let Y=k;if(Y&&typeof Y.cover==="string"&&Y.cover){const V=Y.cover.trim();if(/["<>\s]/.test(V))throw new O("封面图路径/URL 含非法字符(引号/尖括号/空白):"+V.slice(0,120));if(I(V))try{const{dataUri:$}=await E(V);Y={...Y,cover:$}}catch($){throw new O("封面图读取失败:"+V+"("+String($&&$.message||$)+")")}else Y={...Y,cover:V}}await gotoWritePage(K,q.home,q.originRe);const R={title:X,content:y,markdown:q.needsMarkdown===!0&&q.outputFormat!=="markdown"?A:null,html:q.needsHtml===!0&&q.outputFormat!=="html"?J:null,draftOnly:!!D,outputFormat:q.outputFormat,preprocessConfig:q.preprocessConfig||null,imageSpec:q.image?.spec||null,imageSkip:q.image?.skip||[],imageAllowRelative:q.image?.allowRelative===!0,publishParams:Y},j=typeof q.image?.uploadFn==="function"?q.image.uploadFn.toString():null,x=buildPublishJs(R,q.publish.toString(),j),B=await K.evaluate(x);if(!B||B.ok===!1){const V=B?.stage||"publish",$=B?.status!=null?` (HTTP ${B.status})`:"",M=B?.detail!=null?`;接口原始响应:${typeof B.detail==="string"?B.detail:JSON.stringify(B.detail)}`:"";throw new O(`[${V}] ${B?.message||"发布失败"}${$}${M}`)}return{id:B.id,url:B.url,draft:B.draft,images:{uploaded:B.uploaded||[],failed:(B.failed||[]).concat(N)}}}export const __test__={selectContent,originReFromHome,buildPublishJs,publishArticle};
@@ -0,0 +1,10 @@
1
+ import{AuthRequiredError as z}from"@jackwener/opencli/errors";import{registerSiteAuthCommands as H}from"../_shared/site-auth.js";const J="https://discord.com/channels/@me";async function K(b){return b.evaluate(`(() => {
2
+ if (/\\/login(\\b|\\/|\\?|$)/.test(location.pathname)) return null;
3
+ const panel = document.querySelector('[class*=panelTitleContainer]');
4
+ if (!panel) return null;
5
+ const display = (panel.textContent || '').trim();
6
+ const handleEl = document.querySelector('[class*=panelSubtextContainer] [class*=hovered]');
7
+ const handle = handleEl ? (handleEl.textContent || '').trim() : '';
8
+ if (!display && !handle) return null;
9
+ return { display_name: display, username: handle || display };
10
+ })()`)}async function B(b){await b.goto(J);for(let F=0;F<8;F++){const G=await K(b);if(G)return G;await b.wait(1)}const x=await b.evaluate("location.href");if(/\/login/.test(String(x||"")))throw new z("discord.com","Discord redirected to /login — not signed in");throw new z("discord.com","Discord account panel not found — not signed in or app failed to load")}H({site:"discord-web",domain:"discord.com",loginUrl:"https://discord.com/login",columns:["username","display_name"],quickCheck:async(b)=>{try{return{logged_in:!0,...await B(b)}}catch(x){if(x instanceof z)return{logged_in:!1};throw x}},verify:B,poll:async(b)=>B(b)});
@@ -0,0 +1,13 @@
1
+ import{ArgumentError as K,AuthRequiredError as $,CommandExecutionError as P}from"@jackwener/opencli/errors";import{cli as q,Strategy as z}from"@jackwener/opencli/registry";const G=/^(?:@me|\d{3,})\/\d{3,}$/;export function normalizeChannelUrl(b){const f=String(b||"").trim();if(!f)throw new K("Channel is required.",'Pass a channel URL like https://discord.com/channels/<guild>/<channel>, or "<guild>/<channel>".');if(/^https?:\/\//i.test(f)){let J;try{J=new URL(f)}catch{throw new K(`Invalid channel URL: ${f}`)}const M=J.hostname.toLowerCase();if(M!=="discord.com"&&!M.endsWith(".discord.com"))throw new K("Channel URL must be a discord.com URL.");const F=J.pathname.split("/").filter(Boolean);if(F[0]!=="channels"||F.length<3)throw new K("Channel URL must look like /channels/<guild>/<channel>.");return`https://discord.com/channels/${F[1]}/${F[2]}`}if(!G.test(f))throw new K(`Invalid channel reference: ${JSON.stringify(f)}`,'Use "<guild>/<channel>" (numeric ids) or "@me/<dm-channel>", or a full channel URL.');return`https://discord.com/channels/${f}`}q({site:"discord-web",name:"send",access:"write",description:'Send a message to a Discord channel (discord.com). Pass the channel URL or "<guild>/<channel>". Multi-line text is sent as one message.',domain:"discord.com",strategy:z.COOKIE,browser:!0,args:[{name:"channel",type:"string",required:!0,positional:!0,help:'Channel URL (https://discord.com/channels/<guild>/<channel>) or "<guild>/<channel>"'},{name:"text",type:"string",required:!0,positional:!0,help:"Message text (multi-line allowed)"}],columns:["status","channel_id","message"],func:async(b,f)=>{if(!b)throw new P("Browser session required");const J=normalizeChannelUrl(f.channel),M=J.split("/").pop(),F=String(f.text??"");if(!F.trim())throw new K("Message text cannot be empty.");await b.goto(J);let W=!1;for(let B=0;B<12;B++){const Z=await b.evaluate(`(() => {
2
+ if (/\\/login(\\b|\\/|\\?|$)/.test(location.pathname)) return 'login';
3
+ const ed = document.querySelector('[role="textbox"][data-slate-editor="true"]');
4
+ if (!ed) return 'no-editor';
5
+ return 'ready';
6
+ })()`);if(Z==="login")throw new $("discord.com","Discord redirected to /login — not signed in");if(Z==="ready"){W=!0;break}await b.wait(1)}if(!W)throw new P("Discord message box not found — the channel may not exist, or you lack access to it.");const X='[role="textbox"][data-slate-editor="true"]';await b.click(X);await b.pressKey("Control+A");await b.pressKey("Delete");const Q=F.split(`
7
+ `);for(let B=0;B<Q.length;B++){if(B>0)await b.pressKey("Shift+Enter");if(Q[B].length)await b.typeText(X,Q[B])}if(await b.evaluate(`(() => !!document.querySelector('[class*="autocomplete"]'))()`))await b.pressKey("Escape");const V=await b.evaluate(`(() => {
8
+ const ed = document.querySelector('[role="textbox"][data-slate-editor="true"]');
9
+ return ed ? (ed.textContent || '').replace(/\\uFEFF/g, '') : null;
10
+ })()`),Y=(B)=>String(B||"").replace(/\s+/g," ").trim();if(V===null||Y(V)!==Y(F))throw new P(`Composed text did not match before send (got ${JSON.stringify((V||"").slice(0,60))}).`);await b.pressKey("Enter");await b.wait(1);if((await b.evaluate(`(() => {
11
+ const ed = document.querySelector('[role="textbox"][data-slate-editor="true"]');
12
+ return ed ? (ed.textContent || '').replace(/\\uFEFF/g, '') : '';
13
+ })()`)).trim().length>0)throw new P("Message still in the box after Enter — send blocked (slow-mode, rate limit, or no permission to post here).");return[{status:"success",channel_id:M,message:"Message sent"}]}});
@@ -0,0 +1,9 @@
1
+ import{AuthRequiredError as n,CommandExecutionError as c}from"@jackwener/opencli/errors";import{registerSiteAuthCommands as i}from"../_shared/site-auth.js";const t="https://news.ycombinator.com";async function r(e){return(await e.getCookies({url:t})).some((a)=>a.name==="user"&&a.value)}async function s(e){if(!await r(e))throw new n("news.ycombinator.com","Hacker News `user` cookie missing — not signed in");await e.goto(`${t}/news`);await e.wait(1);const o=await e.evaluate(`(() => {
2
+ // The top-right nav shows "<username> (karma) | logout" only when signed in.
3
+ const userLink = document.querySelector('span.pagetop a[href^="user?id="]');
4
+ const logout = document.querySelector('a[href^="logout"]');
5
+ if (!userLink || !logout) return { anon: true };
6
+ const username = (userLink.textContent || '').trim();
7
+ const karmaMatch = (document.querySelector('span.pagetop')?.textContent || '').match(/\\((\\d+)\\)/);
8
+ return { username, karma: karmaMatch ? karmaMatch[1] : '' };
9
+ })()`);if(o?.anon||!o?.username)throw new n("news.ycombinator.com","Hacker News shows no signed-in user — cookie stale");return{username:o.username,karma:o.karma||""}}i({site:"hackernews",domain:"news.ycombinator.com",loginUrl:`${t}/login`,columns:["username","karma"],quickCheck:r,verify:s,poll:async(e)=>{if(!await r(e))throw new n("news.ycombinator.com","Waiting for Hacker News `user` cookie");return s(e)}});
@@ -0,0 +1,42 @@
1
+ import{ArgumentError as H,AuthRequiredError as V,CommandExecutionError as P}from"@jackwener/opencli/errors";import{cli as W,Strategy as X}from"@jackwener/opencli/registry";const Y=/^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/;export function normalizeSubreddit(M){let F=String(M||"").trim();if(!F)throw new H("Subreddit is required.",'Pass a subreddit like "test" or "r/test".');if(/^https?:\/\//i.test(F)){let N;try{N=new URL(F)}catch{throw new H(`Invalid subreddit URL: ${F}`)}const J=N.pathname.split("/").filter(Boolean),K=J.indexOf("r");if(K<0||J.length<=K+1)throw new H("Subreddit URL must contain /r/<name>.");F=J[K+1]}F=F.replace(/^\/?r\//i,"").replace(/^\/+/,"");if(!Y.test(F))throw new H(`Invalid subreddit name: ${JSON.stringify(F)}`,'Use the bare name, e.g. "test" (2–21 chars, letters/digits/underscore).');return F}W({site:"reddit",name:"submit",access:"write",description:"Submit a new post to a subreddit. Text post by default (--text); pass --url for a link post. Note: many subreddits gate submissions behind karma/age requirements or flair — a rejection is surfaced verbatim.",domain:"reddit.com",strategy:X.COOKIE,browser:!0,args:[{name:"subreddit",type:"string",required:!0,positional:!0,help:'Target subreddit, e.g. "test" or "r/test"'},{name:"title",type:"string",required:!0,positional:!0,help:"Post title (max 300 chars)"},{name:"text",type:"string",help:"Body text (markdown) for a self/text post"},{name:"url",type:"string",help:"External URL for a link post (mutually exclusive with --text)"},{name:"flair-id",type:"string",help:"Flair template id, if the subreddit requires one"},{name:"flair-text",type:"string",help:"Flair text to accompany --flair-id"},{name:"nsfw",type:"bool",default:!1,help:"Mark the post as NSFW"},{name:"spoiler",type:"bool",default:!1,help:"Mark the post as a spoiler"},{name:"sendreplies",type:"bool",default:!0,help:"Receive inbox replies for this post (default: true)"}],columns:["status","url","id","message"],func:async(M,F)=>{if(!M)throw new P("Browser session required");const N=normalizeSubreddit(F.subreddit),J=String(F.title??"").trim();if(!J)throw new H("Title is required.");if(J.length>300)throw new H("Title must be at most 300 characters.");const K=typeof F.text==="string"?F.text:"",O=typeof F.url==="string"?F.url.trim():"";if(O&&K)throw new H("Pass either --text (self post) or --url (link post), not both.");const Q=O?"link":"self";if(Q==="link")try{new URL(O)}catch{throw new H(`Invalid --url: ${O}`)}await M.goto("https://www.reddit.com");const G=await M.evaluate(`(async () => {
2
+ try {
3
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
4
+ if (meRes.status === 401 || meRes.status === 403) {
5
+ return { kind: 'auth', detail: 'Reddit /api/me.json HTTP ' + meRes.status };
6
+ }
7
+ const me = await meRes.json();
8
+ const modhash = me && me.data && me.data.modhash;
9
+ if (!modhash) return { kind: 'auth', detail: 'No modhash — anonymous or expired session' };
10
+
11
+ const p = new URLSearchParams();
12
+ p.set('sr', ${JSON.stringify(N)});
13
+ p.set('title', ${JSON.stringify(J)});
14
+ p.set('kind', ${JSON.stringify(Q)});
15
+ ${Q==="link"?`p.set('url', ${JSON.stringify(O)}); p.set('resubmit', 'true');`:`p.set('text', ${JSON.stringify(K)});`}
16
+ p.set('api_type', 'json');
17
+ p.set('validate_on_submit', 'true');
18
+ p.set('sendreplies', ${F.sendreplies===!1?"'false'":"'true'"});
19
+ ${F.nsfw?"p.set('nsfw', 'true');":""}
20
+ ${F.spoiler?"p.set('spoiler', 'true');":""}
21
+ ${F["flair-id"]?`p.set('flair_id', ${JSON.stringify(String(F["flair-id"]))});`:""}
22
+ ${F["flair-text"]?`p.set('flair_text', ${JSON.stringify(String(F["flair-text"]))});`:""}
23
+ p.set('uh', modhash);
24
+
25
+ const res = await fetch('/api/submit', {
26
+ method: 'POST',
27
+ credentials: 'include',
28
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
29
+ body: p.toString(),
30
+ });
31
+ if (!res.ok) return { kind: 'http', httpStatus: res.status };
32
+ const data = await res.json();
33
+ const errors = data && data.json && data.json.errors;
34
+ if (errors && errors.length > 0) {
35
+ return { kind: 'reject', detail: errors.map(e => e.join(': ')).join('; ') };
36
+ }
37
+ const d = (data && data.json && data.json.data) || {};
38
+ return { ok: true, url: d.url || '', id: d.id || (d.name || '') };
39
+ } catch (e) {
40
+ return { kind: 'exception', detail: String((e && e.message) || e) };
41
+ }
42
+ })()`);if(G?.kind==="auth")throw new V("reddit.com",G.detail);if(G?.kind==="http")throw new P(`HTTP ${G.httpStatus} from /api/submit`);if(G?.kind==="exception")throw new P(`Reddit submit failed: ${G.detail}`);if(G?.kind==="reject")return[{status:"failed",url:"",id:"",message:G.detail}];if(!G?.ok)throw new P(`Unexpected Reddit submit result: ${JSON.stringify(G)}`);return[{status:"success",url:G.url,id:G.id,message:"Post submitted to r/"+N}]}});
@@ -1,3 +1,3 @@
1
- import{CliError as N,CommandExecutionError as P}from"@jackwener/opencli/errors";import{cli as v,Strategy as p}from"@jackwener/opencli/registry";import{publishArticle as f}from"../_shared/article/publish.js";import{readFile as I,stat as w}from"node:fs/promises";function u(W){if(!W.execute)throw new N("INVALID_INPUT","本命令需要 --execute 才会真正发布,请确认后加上 --execute。注意:本报错仅表示缺少 --execute,并未校验其余参数,不能当 dry-run 预览用")}async function h(W){const H=typeof W.text==="string"?W.text:void 0,V=typeof W.file==="string"?W.file:void 0;if(H&&V)throw new N("INVALID_INPUT","请只传 <text> 或 --file 之一,不能同时使用");let B=H??"";if(V){let A;try{A=await w(V)}catch{throw new N("INVALID_INPUT",`文件不存在:${V}`)}if(!A.isFile())throw new N("INVALID_INPUT",`路径必须是可读文本文件:${V}`);let M;try{M=await I(V)}catch{throw new N("INVALID_INPUT",`无法读取文件:${V}`)}try{B=new TextDecoder("utf-8",{fatal:!0}).decode(M)}catch{throw new N("INVALID_INPUT",`文件编码不是 UTF-8:${V}`)}}if(!B.trim())throw new N("INVALID_INPUT","正文不能为空");return B}function x(W,H,V,B,A={}){return[{status:"success",outcome:B,message:W,target_type:H,target:V,...A}]}export const segmentfaultProfile={home:"https://segmentfault.com/write",outputFormat:"markdown",image:{skip:["image-static.segmentfault.com","avatar-static.segmentfault.com"],uploadFn:async(W,H)=>{const B=await(await fetch("https://segmentfault.com/write",{credentials:"include"})).text();let A=null;const M=B.match(/serverData"\s*:\s*\{\s*"Token"\s*:\s*"([^"]+)"/);if(M)A=M[1];else{const U="window.g_initialProps = ",R=B.indexOf(U);if(R!==-1){const q=B.indexOf(`;
1
+ import{CliError as N,CommandExecutionError as P}from"@jackwener/opencli/errors";import{cli as v,Strategy as p}from"@jackwener/opencli/registry";import{publishArticle as f}from"../_shared/article/publish.js";import{readFile as I,stat as w}from"node:fs/promises";function u(W){if(!W.execute)throw new N("INVALID_INPUT","本命令需要 --execute 才会真正发布,请确认后加上 --execute。注意:本报错仅表示缺少 --execute,并未校验其余参数,不能当 dry-run 预览用")}async function h(W){const H=typeof W.text==="string"?W.text:void 0,V=typeof W.file==="string"?W.file:void 0;if(H&&V)throw new N("INVALID_INPUT","请只传 <text> 或 --file 之一,不能同时使用");let B=H??"";if(V){let A;try{A=await w(V)}catch{throw new N("INVALID_INPUT",`文件不存在:${V}`)}if(!A.isFile())throw new N("INVALID_INPUT",`路径必须是可读文本文件:${V}`);let M;try{M=await I(V)}catch{throw new N("INVALID_INPUT",`无法读取文件:${V}`)}try{B=new TextDecoder("utf-8",{fatal:!0}).decode(M)}catch{throw new N("INVALID_INPUT",`文件编码不是 UTF-8:${V}`)}}if(!B.trim())throw new N("INVALID_INPUT","正文不能为空");return B}function x(W,H,V,B,A={}){return[{status:"success",outcome:B,message:W,target_type:H,target:V,...A}]}export const segmentfaultProfile={home:"https://segmentfault.com/write",outputFormat:"markdown",image:{skip:["image-static.segmentfault.com","avatar-static.segmentfault.com"],allowRelative:!0,uploadFn:async(W,H)=>{const B=await(await fetch("https://segmentfault.com/write",{credentials:"include"})).text();let A=null;const M=B.match(/serverData"\s*:\s*\{\s*"Token"\s*:\s*"([^"]+)"/);if(M)A=M[1];else{const U="window.g_initialProps = ",R=B.indexOf(U);if(R!==-1){const q=B.indexOf(`;
2
2
  </script>`,R);if(q!==-1)try{const L=JSON.parse(B.substring(R+U.length,q));A=L&&L.global&&L.global.sessionInfo&&L.global.sessionInfo.key}catch(L){}}}if(!A)throw Error("获取思否 session token 失败");const O=await fetch(W,{credentials:"omit"});if(!O.ok)throw Error("下载图片失败:"+O.status);const b=await O.blob(),E=new FormData;E.append("image",b);const F=await(await fetch("https://segmentfault.com/gateway/image",{method:"POST",credentials:"include",headers:{token:A},body:E})).text();if(F==="Unauthorized"||F.includes("禁言")||F.includes("锁定"))throw Error(F==="Unauthorized"?"未授权,请检查登录态":F);let X;try{X=JSON.parse(F)}catch(U){throw Error("图片上传失败:"+F)}let T=null;if(X&&X.url)T=X.url;else if(X&&X.result)T=X.result;else if(Array.isArray(X)){if(X[0]===1)throw Error(X[1]||"图片上传失败");T=X[1]||(X[2]?"https://image-static.segmentfault.com/"+X[2]:null)}if(!T)throw Error("图片上传失败,服务端未返回 URL");return{url:T}}},publish:async(W,H)=>{const V=W.params||{},A=await(await fetch("https://segmentfault.com/write",{credentials:"include"})).text();let M=null;const O=A.match(/serverData"\s*:\s*\{\s*"Token"\s*:\s*"([^"]+)"/);if(O)M=O[1];else{const Q="window.g_initialProps = ",z=A.indexOf(Q);if(z!==-1){const J=A.indexOf(`;
3
3
  </script>`,z);if(J!==-1)try{const K=JSON.parse(A.substring(z+Q.length,J));M=K&&K.global&&K.global.sessionInfo&&K.global.sessionInfo.key}catch(K){}}}if(!M)return{ok:!1,stage:"token",status:0,message:"获取思否 session token 失败:请确认已登录;账号首次发文会被新手确认页(/howtowrite)拦住,需先在网页端进一次写作页点「我已知晓,继续撰写」"};async function b(Q){let z=null;try{const D=(await fetch("/",{method:"HEAD"})).headers.get("date");if(D){const S=new Date(D).getTime();if(!isNaN(S))z=Math.floor(S/1000)}}catch($){}const J=Object.keys(window).find(($)=>$.startsWith("webpackChunk"));if(!J)return{__sfErr:"页面未就绪:未找到 webpack 运行时,无法复用思否请求签名"};let K;try{window[J].push([[Symbol("pp_sf_"+Math.random())],{},($)=>{K=$}])}catch($){return{__sfErr:"注入 webpack 探针失败:"+String($)}}if(!K||!K.m)return{__sfErr:"无法取得 webpack require(思否前端结构可能已变)"};let Y=null;for(const $ of Object.keys(K.m)){let D="";try{D=String(K.m[$])}catch(S){continue}if(/function getUrl/.test(D)&&D.indexOf("/gateway")!==-1){Y=$;break}}if(!Y)return{__sfErr:"未定位到思否请求签名模块(getUrl/gateway),站点前端结构可能已变,需更新 adapter"};let j;try{j=K(Y).default}catch($){return{__sfErr:"加载思否请求模块失败:"+String($)}}if(typeof j!=="function")return{__sfErr:"思否请求模块导出异常(default 非构造器)"};let Z=null;const C=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function($,D){try{if(String(D).indexOf(Q)!==-1)Z=this}catch(S){}return C.apply(this,arguments)};try{const $=new j({Token:M,userAgent:navigator.userAgent});if(z)$.setServerTime(z);await $.send(Q,{method:"GET"}).catch(function(){})}catch($){XMLHttpRequest.prototype.open=C;return{__sfErr:"签名请求执行失败:"+String($)}}XMLHttpRequest.prototype.open=C;if(!Z)return{__sfErr:"签名请求未发出(Request.send 未触发 XHR)"};return{status:Z.status,raw:String(Z.responseText||"")}}if(W.draftOnly){const Q={title:W.title,tags:[],text:W.content,object_id:"",type:"article"},z=await fetch("https://segmentfault.com/gateway/draft",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",token:M,accept:"*/*"},body:JSON.stringify(Q)}),J=await z.text();if(J==="Unauthorized"||J.includes("禁言")||J.includes("锁定"))return{ok:!1,stage:"publish",status:z.status,message:J==="Unauthorized"?"未授权":J};let K;try{K=JSON.parse(J)}catch(Y){return{ok:!1,stage:"publish",status:z.status,message:"解析响应失败:"+J.slice(0,200)}}if(Array.isArray(K)){if(K[0]===1)return{ok:!1,stage:"publish",status:z.status,message:K[1]||"发布失败"};const Y=K[1];if(Y&&Y.id)return{ok:!0,draft:!0,id:String(Y.id),url:"https://segmentfault.com/write?draftId="+Y.id}}if(!K||!K.id){const Y=K&&(K.message||K.msg||K.error||K.errMsg)||J.slice(0,200);return{ok:!1,stage:"publish",status:z.status,message:Y}}return{ok:!0,draft:!0,id:String(K.id),url:"https://segmentfault.com/write?draftId="+K.id}}const E={"Content-Type":"application/json",token:M,accept:"*/*"},_=Array.isArray(V.tags)?V.tags.map((Q)=>String(Q).trim()).filter(Boolean):[];if(_.length===0)return{ok:!1,stage:"tags",message:"思否发文必须至少指定 1 个标签(--tags),合法标签可用 `segmentfault tags <名称>` 校验"};const F=[];for(const Q of _){const z=await b("/tag/"+encodeURIComponent(Q));if(z&&z.__sfErr)return{ok:!1,stage:"tags",message:"标签「"+Q+"」校验失败:"+z.__sfErr};let J;try{J=JSON.parse(z.raw)}catch(Y){return{ok:!1,stage:"tags",status:z.status,message:"解析标签「"+Q+"」响应失败(HTTP "+z.status+"):"+String(z.raw).slice(0,200)}}if(z.status!==200||typeof J==="string")return{ok:!1,stage:"tags",status:z.status,message:"思否标签接口请求被拒(HTTP "+z.status+"):"+(typeof J==="string"?J:String(z.raw).slice(0,120))};const K=J&&J.tag;if(!K||K.id==null)return{ok:!1,stage:"tags",message:"思否上找不到标签「"+Q+"」,请用 `segmentfault tags <名称>` 确认存在再发布"};F.push(K.id)}const X=Array.isArray(V.channels)?V.channels.map((Q)=>String(Q).trim()).filter(Boolean):[],T=[];if(X.length>0){let Q=[];const z=await b("/channels");if(z&&z.__sfErr)return{ok:!1,stage:"channels",message:"获取思否频道列表失败:"+z.__sfErr};let J;try{J=JSON.parse(z.raw)}catch(K){return{ok:!1,stage:"channels",status:z.status,message:"解析频道列表失败(HTTP "+z.status+"):"+String(z.raw).slice(0,200)}}if(z.status!==200||typeof J==="string")return{ok:!1,stage:"channels",status:z.status,message:"思否频道接口请求被拒(HTTP "+z.status+"):"+(typeof J==="string"?J:String(z.raw).slice(0,120))};if(Array.isArray(J))Q=J;else if(J&&Array.isArray(J.data))Q=J.data;else if(J&&Array.isArray(J.rows))Q=J.rows;else if(J&&Array.isArray(J.channels))Q=J.channels;else Q=[];for(const K of X){const Y=Q.find((j)=>j&&(String(j.name||j.title||j.text)===K||String(j.slug)===K));if(!Y||!Y.slug){const j=Q.map((Z)=>Z&&(Z.name||Z.title||Z.text)||"").filter(Boolean).join(" / ");return{ok:!1,stage:"channels",message:"思否上找不到频道「"+K+"」,可选:"+(j||"(频道列表为空)")}}T.push(Y.slug)}}let U="";if(V.cover){const Q=String(V.cover);if(Q.indexOf("/img/")===0)U=Q;else{let z;try{const Z=await fetch(Q,{credentials:"omit"});if(!Z.ok)return{ok:!1,stage:"cover",status:Z.status,message:"封面图下载失败(HTTP "+Z.status+"):"+Q.slice(0,120)};z=await Z.blob()}catch(Z){return{ok:!1,stage:"cover",message:"封面图下载失败("+String(Z&&Z.message||Z)+")。思否封面只收自家图床 /img/ 路径;建议 --cover 直接传本机图片路径,由命令自动转存"}}const J=new FormData;J.append("image",z);const K=await fetch("https://segmentfault.com/gateway/image",{method:"POST",credentials:"include",headers:{token:M},body:J}),Y=await K.text();let j;try{j=JSON.parse(Y)}catch(Z){return{ok:!1,stage:"cover",status:K.status,message:"封面图上传思否图床失败:"+Y.slice(0,120)}}U=j&&j.url||"";if(!U)return{ok:!1,stage:"cover",status:K.status,message:"封面图上传后未拿到 /img/ 路径:"+Y.slice(0,120)}}}const R={title:W.title,text:W.content,tags:F,blog_id:0,type:1};if(T.length>0)R.channel=T;if(U)R.cover=U;const q=await fetch("https://segmentfault.com/gateway/article",{method:"POST",credentials:"include",headers:E,body:JSON.stringify(R)}),L=await q.text();if(L==="Unauthorized"||L.includes("禁言")||L.includes("锁定"))return{ok:!1,stage:"publish",status:q.status,message:L==="Unauthorized"?"未授权,请检查登录态":L};let G;try{G=JSON.parse(L)}catch(Q){return{ok:!1,stage:"publish",status:q.status,message:"解析发布响应失败:"+L.slice(0,200)}}if(G&&(G.isError||G.error||G.errno||G.scene_id)){let Q=G.message||G.msg||G.error||"";if(G.scene_id)Q=(Q?Q+";":"")+"思否要求人机验证(geetest),无法在自动化环境完成";return{ok:!1,stage:"publish",status:q.status,message:Q||"发布失败:"+L.slice(0,200)}}const y=G&&G.data&&G.data.id;if(y==null)return{ok:!1,stage:"publish",status:q.status,message:G&&(G.message||G.msg)||"发布响应缺少文章 id:"+L.slice(0,200)};return{ok:!0,draft:!1,id:String(y),url:"https://segmentfault.com/a/"+y}}};v({site:"segmentfault",name:"article",access:"write",description:"发布文章到思否(SegmentFault)。默认正式发布,加 --draft 仅存草稿。正文默认 Markdown;外链图片自动转存到思否图床。正式发布必须指定标签(--tags),合法标签用 `segmentfault tags <名称>` 校验、频道用 `segmentfault channels` 列举。",domain:"segmentfault.com",strategy:p.COOKIE,browser:!0,args:[{name:"title",positional:!0,required:!0,help:"文章标题"},{name:"text",positional:!0,help:"文章正文(默认 Markdown;传 --html 则视为 HTML)"},{name:"file",help:"正文文件路径(UTF-8,默认 Markdown)"},{name:"html",type:"boolean",help:"将正文视为原始 HTML 而非 Markdown"},{name:"tags",help:"标签名,逗号分隔(正式发布必填,至少 1 个)。合法标签用 `segmentfault tags <名称>` 校验;草稿可省略。"},{name:"cover",help:"封面图(选填):本机图片路径或图片 URL,自动转存到思否图床。"},{name:"channels",help:"频道名,逗号分隔(选填)。合法值用 `segmentfault channels` 列举。"},{name:"draft",type:"boolean",help:"仅保存草稿,不正式发布"},{name:"execute",type:"boolean",help:"实际执行发布。不加此参数时命令拒绝写操作。"}],columns:["status","outcome","message","target_type","target","created_target","created_url"],func:async(W,H)=>{if(!W)throw new P("思否发布需要浏览器会话");u(H);const V=String(H.title??"").trim();if(!V)throw new N("INVALID_INPUT","文章标题不能为空");const B=await h(H),A=Boolean(H.draft),M=(U)=>typeof U==="string"?U.split(",").map((R)=>R.trim()).filter(Boolean):[],O=M(H.tags),b=M(H.channels),E=typeof H.cover==="string"?H.cover.trim():"";if(!A&&O.length===0)throw new N("INVALID_INPUT","思否正式发布必须指定 --tags(至少 1 个标签);合法标签用 `segmentfault tags <名称>` 校验,或加 --draft 只存草稿");const _=await f(W,{title:V,body:B,format:H.html?"html":"markdown",draftOnly:A,profile:segmentfaultProfile,publishParams:{tags:O,channels:b,cover:E}}),F=_.images.uploaded.length|0,X=_.images.failed.length|0;let T=_.draft?"已保存到思否草稿箱(需在网页上手动发布)":"已正式发布到思否";if(F||X)T+=`;图片:${F} 张转存成功${X?`,${X} 张失败`:""}`;return x(T,"article","",_.draft?"draft":"created",{created_target:(_.draft?"draft:":"article:")+_.id,created_url:_.url})}});
@@ -1,5 +1,5 @@
1
- import{sendCommand as L,sendCommandFull as Y}from"./daemon-client.js";import{buildEvaluateExpression as $}from"./utils.js";import{saveBase64ToFile as N}from"../utils.js";import{generateStealthJs as j}from"./stealth.js";import{waitForDomStableJs as O}from"./dom-helpers.js";import{BasePage as U}from"./base-page.js";import{classifyBrowserError as _}from"./errors.js";import{log as R}from"../logger.js";function J(A){const G=(A instanceof Error?A.message:String(A)).toLowerCase();return G.includes("unknown action")&&G.includes("network-capture")||G.includes("network capture")&&G.includes("not supported")}function h(A){const D=A instanceof Error?A.message:String(A);return D.includes("stale page identity")||/^Page not found:\s*\S+\s*$/.test(D)}export class Page extends U{session;contextId;windowMode;surface;siteSession;_idleTimeout;constructor(A,D,G,H,V="browser",Q){super();this.session=A;this.contextId=G;this.windowMode=H;this.surface=V;this.siteSession=Q;this._idleTimeout=D}_page;_networkCaptureUnsupported=!1;_networkCaptureWarned=!1;_sessionOpts(){return{session:this.session,surface:this.surface,...this.contextId&&{contextId:this.contextId},...this._idleTimeout!=null&&{idleTimeout:this._idleTimeout},...this.windowMode&&{windowMode:this.windowMode},...this.siteSession&&{siteSession:this.siteSession}}}_cmdOpts(){return{session:this.session,surface:this.surface,...this.contextId&&{contextId:this.contextId},...this._page!==void 0&&{page:this._page},...this._idleTimeout!=null&&{idleTimeout:this._idleTimeout},...this.windowMode&&{windowMode:this.windowMode},...this.siteSession&&{siteSession:this.siteSession}}}async goto(A,D){let G;try{G=await Y("navigate",{url:A,...this._cmdOpts()})}catch(H){if(!h(H)||this._page===void 0)throw H;this._page=void 0;G=await Y("navigate",{url:A,...this._cmdOpts()})}if(G.page)this._page=G.page;this._lastUrl=A;if(D?.waitUntil!=="none"){const H=D?.settleMs??1000,Q={code:`${j()};
2
- ${O(H,Math.min(500,H))}`,...this._cmdOpts()};try{await L("exec",Q)}catch(W){const K=_(W);if(K.kind!=="target-navigation")throw W;try{await new Promise((X)=>setTimeout(X,K.delayMs));await L("exec",Q)}catch(X){if(_(X).kind!=="target-navigation")throw X}}}else try{await L("exec",{code:j(),...this._cmdOpts()})}catch{}}getActivePage(){return this._page}setActivePage(A){this._page=A;this._lastUrl=null}_markUnsupportedNetworkCapture(){this._networkCaptureUnsupported=!0;if(this._networkCaptureWarned)return;this._networkCaptureWarned=!0;R.warn("Browser Bridge extension does not support network capture; continuing without it. Explore output may miss API endpoints until you reload or reinstall the extension.")}async evaluate(A,...D){const G=$(A,D);try{return await L("exec",{code:G,...this._cmdOpts()})}catch(H){const V=_(H);if(V.kind!=="target-navigation")throw H;await new Promise((Q)=>setTimeout(Q,V.delayMs));return L("exec",{code:G,...this._cmdOpts()})}}async getCookies(A={}){const D=await L("cookies",{...this._sessionOpts(),...A});return Array.isArray(D)?D:[]}async closeWindow(){try{await L("close-window",{...this._sessionOpts()})}catch{}finally{this._page=void 0;this._lastUrl=null;this._networkCaptureUnsupported=!1;this._networkCaptureWarned=!1}}async tabs(){const A=await L("tabs",{op:"list",...this._sessionOpts()});return Array.isArray(A)?A:[]}async newTab(A){const D=await Y("tabs",{op:"new",...A!==void 0&&{url:A},...this._sessionOpts()});this._lastUrl=null;return D.page}async closeTab(A){const D={op:"close",...this._sessionOpts()};if(typeof A==="number")D.index=A;else if(typeof A==="string")D.page=A;else if(this._page!==void 0)D.page=this._page;const G=await L("tabs",D),H=typeof G?.closed==="string"?G.closed:void 0;if(H&&H===this._page||!H&&(A===void 0||A===this._page)){this._page=void 0;this._lastUrl=null}}async selectTab(A){const D=await Y("tabs",{op:"select",...typeof A==="number"?{index:A}:{page:A},...this._sessionOpts()});if(D.page)this._page=D.page;this._lastUrl=null}async screenshot(A={}){const D=await L("screenshot",{...this._cmdOpts(),format:A.format,quality:A.quality,fullPage:A.fullPage,width:A.width,height:A.height});if(A.path)await N(D,A.path);return D}async startNetworkCapture(A=""){if(this._networkCaptureUnsupported)return!1;try{await L("network-capture-start",{pattern:A,...this._cmdOpts()});return!0}catch(D){if(!J(D))throw D;this._markUnsupportedNetworkCapture();return!1}}async readNetworkCapture(){if(this._networkCaptureUnsupported)return[];try{const A=await L("network-capture-read",{...this._cmdOpts()});return Array.isArray(A)?A:[]}catch(A){if(!J(A))throw A;this._markUnsupportedNetworkCapture();return[]}}async waitForDownload(A="",D=30000){return await L("wait-download",{pattern:A,timeoutMs:D,...this._cmdOpts()})}async setFileInput(A,D){if(!(await L("set-file-input",{files:A,selector:D,...this._cmdOpts()}))?.count)throw Error("setFileInput returned no count — command may not be supported by the extension")}async insertText(A){if(!(await L("insert-text",{text:A,...this._cmdOpts()}))?.inserted)throw Error("insertText returned no inserted flag — command may not be supported by the extension")}async frames(){const A=await L("frames",{...this._cmdOpts()});return Array.isArray(A)?A:[]}async evaluateInFrame(A,D){const G=$(A);return L("exec",{code:G,frameIndex:D,...this._cmdOpts()})}async cdp(A,D={}){return L("cdp",{cdpMethod:A,cdpParams:D,...this._cmdOpts()})}async handleJavaScriptDialog(A,D){await this.cdp("Page.handleJavaScriptDialog",{accept:A,...D!==void 0&&{promptText:D}})}async tryNativeClick(A,D){try{await this.nativeClick(A,D);return!0}catch{return!1}}async clickWithQuads(A){const D=JSON.stringify(A),G=`[data-opencli-ref="${A.replace(/"/g,"\\\"")}"]`;await this.evaluate(`
1
+ import{sendCommand as L,sendCommandFull as Y}from"./daemon-client.js";import{buildEvaluateExpression as $}from"./utils.js";import{saveBase64ToFile as N}from"../utils.js";import{generateStealthJs as j,generateBeforeUnloadGuardJs as O}from"./stealth.js";import{waitForDomStableJs as U}from"./dom-helpers.js";import{BasePage as R}from"./base-page.js";import{classifyBrowserError as _}from"./errors.js";import{log as h}from"../logger.js";function J(A){const G=(A instanceof Error?A.message:String(A)).toLowerCase();return G.includes("unknown action")&&G.includes("network-capture")||G.includes("network capture")&&G.includes("not supported")}function z(A){const D=A instanceof Error?A.message:String(A);return D.includes("stale page identity")||/^Page not found:\s*\S+\s*$/.test(D)}export class Page extends R{session;contextId;windowMode;surface;siteSession;_idleTimeout;constructor(A,D,G,H,V="browser",Q){super();this.session=A;this.contextId=G;this.windowMode=H;this.surface=V;this.siteSession=Q;this._idleTimeout=D}_page;_networkCaptureUnsupported=!1;_networkCaptureWarned=!1;_sessionOpts(){return{session:this.session,surface:this.surface,...this.contextId&&{contextId:this.contextId},...this._idleTimeout!=null&&{idleTimeout:this._idleTimeout},...this.windowMode&&{windowMode:this.windowMode},...this.siteSession&&{siteSession:this.siteSession}}}_cmdOpts(){return{session:this.session,surface:this.surface,...this.contextId&&{contextId:this.contextId},...this._page!==void 0&&{page:this._page},...this._idleTimeout!=null&&{idleTimeout:this._idleTimeout},...this.windowMode&&{windowMode:this.windowMode},...this.siteSession&&{siteSession:this.siteSession}}}async goto(A,D){try{await L("exec",{code:O(),...this._cmdOpts()})}catch{}let G;try{G=await Y("navigate",{url:A,...this._cmdOpts()})}catch(H){if(!z(H)||this._page===void 0)throw H;this._page=void 0;G=await Y("navigate",{url:A,...this._cmdOpts()})}if(G.page)this._page=G.page;this._lastUrl=A;if(D?.waitUntil!=="none"){const H=D?.settleMs??1000,Q={code:`${j()};
2
+ ${U(H,Math.min(500,H))}`,...this._cmdOpts()};try{await L("exec",Q)}catch(W){const K=_(W);if(K.kind!=="target-navigation")throw W;try{await new Promise((X)=>setTimeout(X,K.delayMs));await L("exec",Q)}catch(X){if(_(X).kind!=="target-navigation")throw X}}}else try{await L("exec",{code:j(),...this._cmdOpts()})}catch{}}getActivePage(){return this._page}setActivePage(A){this._page=A;this._lastUrl=null}_markUnsupportedNetworkCapture(){this._networkCaptureUnsupported=!0;if(this._networkCaptureWarned)return;this._networkCaptureWarned=!0;h.warn("Browser Bridge extension does not support network capture; continuing without it. Explore output may miss API endpoints until you reload or reinstall the extension.")}async evaluate(A,...D){const G=$(A,D);try{return await L("exec",{code:G,...this._cmdOpts()})}catch(H){const V=_(H);if(V.kind!=="target-navigation")throw H;await new Promise((Q)=>setTimeout(Q,V.delayMs));return L("exec",{code:G,...this._cmdOpts()})}}async getCookies(A={}){const D=await L("cookies",{...this._sessionOpts(),...A});return Array.isArray(D)?D:[]}async closeWindow(){try{await L("close-window",{...this._sessionOpts()})}catch{}finally{this._page=void 0;this._lastUrl=null;this._networkCaptureUnsupported=!1;this._networkCaptureWarned=!1}}async tabs(){const A=await L("tabs",{op:"list",...this._sessionOpts()});return Array.isArray(A)?A:[]}async newTab(A){const D=await Y("tabs",{op:"new",...A!==void 0&&{url:A},...this._sessionOpts()});this._lastUrl=null;return D.page}async closeTab(A){const D={op:"close",...this._sessionOpts()};if(typeof A==="number")D.index=A;else if(typeof A==="string")D.page=A;else if(this._page!==void 0)D.page=this._page;const G=await L("tabs",D),H=typeof G?.closed==="string"?G.closed:void 0;if(H&&H===this._page||!H&&(A===void 0||A===this._page)){this._page=void 0;this._lastUrl=null}}async selectTab(A){const D=await Y("tabs",{op:"select",...typeof A==="number"?{index:A}:{page:A},...this._sessionOpts()});if(D.page)this._page=D.page;this._lastUrl=null}async screenshot(A={}){const D=await L("screenshot",{...this._cmdOpts(),format:A.format,quality:A.quality,fullPage:A.fullPage,width:A.width,height:A.height});if(A.path)await N(D,A.path);return D}async startNetworkCapture(A=""){if(this._networkCaptureUnsupported)return!1;try{await L("network-capture-start",{pattern:A,...this._cmdOpts()});return!0}catch(D){if(!J(D))throw D;this._markUnsupportedNetworkCapture();return!1}}async readNetworkCapture(){if(this._networkCaptureUnsupported)return[];try{const A=await L("network-capture-read",{...this._cmdOpts()});return Array.isArray(A)?A:[]}catch(A){if(!J(A))throw A;this._markUnsupportedNetworkCapture();return[]}}async waitForDownload(A="",D=30000){return await L("wait-download",{pattern:A,timeoutMs:D,...this._cmdOpts()})}async setFileInput(A,D){if(!(await L("set-file-input",{files:A,selector:D,...this._cmdOpts()}))?.count)throw Error("setFileInput returned no count — command may not be supported by the extension")}async insertText(A){if(!(await L("insert-text",{text:A,...this._cmdOpts()}))?.inserted)throw Error("insertText returned no inserted flag — command may not be supported by the extension")}async frames(){const A=await L("frames",{...this._cmdOpts()});return Array.isArray(A)?A:[]}async evaluateInFrame(A,D){const G=$(A);return L("exec",{code:G,frameIndex:D,...this._cmdOpts()})}async cdp(A,D={}){return L("cdp",{cdpMethod:A,cdpParams:D,...this._cmdOpts()})}async handleJavaScriptDialog(A,D){await this.cdp("Page.handleJavaScriptDialog",{accept:A,...D!==void 0&&{promptText:D}})}async tryNativeClick(A,D){try{await this.nativeClick(A,D);return!0}catch{return!1}}async clickWithQuads(A){const D=JSON.stringify(A),G=`[data-opencli-ref="${A.replace(/"/g,"\\\"")}"]`;await this.evaluate(`
3
3
  (() => {
4
4
  const el = document.querySelector('[data-opencli-ref="' + ${D} + '"]');
5
5
  if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
@@ -8,4 +8,11 @@
8
8
  *
9
9
  * Inspired by puppeteer-extra-plugin-stealth.
10
10
  */
11
+ /**
12
+ * [pp-only] 单独的 beforeunload 中和脚本,供 page.goto() 在 navigate **之前**
13
+ * 对【当前 document】跑一次——堵住 stealth 覆盖不到的「离开当前页」盲区
14
+ * (stealth 是导航后注入的,兜不住离开旧页那一刻弹的框)。无 stealth guard,
15
+ * 每次都真正执行,幂等安全。
16
+ */
17
+ export declare function generateBeforeUnloadGuardJs(): string;
11
18
  export declare function generateStealthJs(): string;
@@ -1,4 +1,43 @@
1
- let e;export function generateStealthJs(){if(e!==void 0)return e;return e=`
1
+ let j;const k=`
2
+ (function () {
3
+ try {
4
+ var _ep = EventTarget.prototype;
5
+ var _nativeAdd = _ep.__buNativeAdd || _ep.addEventListener;
6
+ try {
7
+ _nativeAdd.call(window, 'beforeunload', function (e) {
8
+ try { e.stopImmediatePropagation(); } catch (x) {}
9
+ try { e.preventDefault(); } catch (x) {}
10
+ try { delete e.returnValue; } catch (x) {}
11
+ try { e.returnValue = undefined; } catch (x) {}
12
+ }, true);
13
+ } catch (e) {}
14
+ try { window.onbeforeunload = null; } catch (e) {}
15
+ try {
16
+ Object.defineProperty(window, 'onbeforeunload', { get: function () { return null; }, set: function () {}, configurable: true });
17
+ } catch (e) {}
18
+ if (!_ep.__buGuard) {
19
+ var _add = _ep.addEventListener;
20
+ var _rm = _ep.removeEventListener;
21
+ var addP = function (type, listener, options) {
22
+ if (type === 'beforeunload') return;
23
+ return _add.call(this, type, listener, options);
24
+ };
25
+ var rmP = function (type, listener, options) {
26
+ if (type === 'beforeunload') return;
27
+ return _rm.call(this, type, listener, options);
28
+ };
29
+ try { Object.defineProperty(addP, 'name', { value: 'addEventListener' }); } catch (e) {}
30
+ try { Object.defineProperty(rmP, 'name', { value: 'removeEventListener' }); } catch (e) {}
31
+ try { addP.toString = function () { return _add.toString(); }; } catch (e) {}
32
+ try { rmP.toString = function () { return _rm.toString(); }; } catch (e) {}
33
+ try { Object.defineProperty(_ep, '__buNativeAdd', { value: _add, enumerable: false, configurable: true }); } catch (e) {}
34
+ _ep.addEventListener = addP;
35
+ _ep.removeEventListener = rmP;
36
+ try { Object.defineProperty(_ep, '__buGuard', { value: true, enumerable: false, configurable: true }); } catch (e) {}
37
+ }
38
+ } catch (e) {}
39
+ })();
40
+ `;export function generateBeforeUnloadGuardJs(){return k}export function generateStealthJs(){if(j!==void 0)return j;return j=`
2
41
  (() => {
3
42
  // Guard: prevent double-injection across separate CDP evaluations.
4
43
  // We cannot use a closure variable (each eval is a fresh scope), and
@@ -341,26 +380,13 @@ let e;export function generateStealthJs(){if(e!==void 0)return e;return e=`
341
380
  // navigate/exec aborts with "This operation was aborted" until a
342
381
  // human manually clicks "Stay". This repeatedly broke twitter
343
382
  // post/reply on the image->text transition and poisoned later reads.
344
- // Defense: a capturing-phase listener that swallows the event, plus
345
- // an accessor on window.onbeforeunload that silently drops future
346
- // assignments so no dialog can ever open. Runs in stealth (injected
347
- // on every navigation), covering post, reply, and reads uniformly.
348
- try {
349
- window.addEventListener('beforeunload', (e) => {
350
- try { e.stopImmediatePropagation(); } catch {}
351
- try { e.preventDefault(); } catch {}
352
- try { delete e.returnValue; } catch {}
353
- try { e.returnValue = undefined; } catch {}
354
- }, true);
355
- try { window.onbeforeunload = null; } catch {}
356
- try {
357
- Object.defineProperty(window, 'onbeforeunload', {
358
- get: () => null,
359
- set: () => {},
360
- configurable: true,
361
- });
362
- } catch {}
363
- } catch {}
383
+ // Defense (shared BEFOREUNLOAD_NEUTRALIZE): swallow already-armed
384
+ // handlers in the capture phase, drop future addEventListener
385
+ // registrations at the source, and no-op the onbeforeunload setter
386
+ // so no dialog can ever open. Injected on every navigation, covering
387
+ // post, reply, and reads uniformly; page.goto() also runs the same
388
+ // snippet before navigating away to cover the current document.
389
+ ${k}
364
390
 
365
391
  return 'applied';
366
392
  })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publishport-opencli",
3
- "version": "1.8.5-pp.33",
3
+ "version": "1.8.5-pp.35",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": false