publishport-opencli 1.0.23 → 1.0.24
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/cli-manifest.json +71 -11
- package/clis/boss/__fixtures__/detail.html +30 -0
- package/clis/boss/detail.js +12 -1
- package/clis/boss/search.js +1 -1
- package/clis/douyin/stats.js +1 -1
- package/clis/ke/auth.js +5 -3
- package/clis/nowcoder/article.js +5 -0
- package/clis/nowcoder/delete.js +1 -1
- package/clis/nowcoder/utils.js +14 -13
- package/clis/spotify/spotify.js +1 -1
- package/clis/xiaohongshu/ask.js +41 -7
- package/clis/xiaohongshu/comments.js +3 -3
- package/clis/xiaohongshu/download.js +2 -2
- package/clis/xiaohongshu/note.js +20 -5
- package/clis/xiaohongshu/risk-control.js +1 -0
- package/dist/src/discovery.js +2 -2
- package/dist/src/doctor.d.ts +2 -1
- package/dist/src/doctor.js +7 -7
- package/dist/src/errors.js +2 -2
- package/dist/src/execution.js +2 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ open the app once and use the System page to install or repair the `opencli`
|
|
|
31
31
|
command.
|
|
32
32
|
|
|
33
33
|
**Option B — npm global install (CLI-only / CI / servers):**
|
|
34
|
-
ppcli requires **Node.js >= 20** when installed through npm.
|
|
34
|
+
ppcli requires **Node.js >= 20.18.1** when installed through npm.
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
node --version
|
|
@@ -287,7 +287,7 @@ See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
|
|
287
287
|
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed from the [Chrome Web Store](https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk) and **enabled** in `chrome://extensions`.
|
|
288
288
|
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
|
|
289
289
|
- **Empty data or 'Unauthorized' error** — Your Chrome/Chromium login session may have expired. Navigate to the target site and log in again.
|
|
290
|
-
- **Node API errors / missing `fetch` / startup crash on old Node** — ppcli requires **Node.js >= 20**. Run `node --version`, upgrade Node if needed, then retry.
|
|
290
|
+
- **Node API errors / missing `fetch` / startup crash on old Node** — ppcli requires **Node.js >= 20.18.1**. Run `node --version`, upgrade Node if needed, then retry.
|
|
291
291
|
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
|
|
292
292
|
|
|
293
293
|
## Star History
|
package/README.zh-CN.md
CHANGED
|
@@ -30,7 +30,7 @@ UI 来做环境诊断、更新、浏览器登录态保活和网页转 Markdown
|
|
|
30
30
|
System 页面安装或修复 `opencli` 命令。
|
|
31
31
|
|
|
32
32
|
**方式 B — npm 全局安装(纯 CLI / CI / 服务器):**
|
|
33
|
-
通过 npm 安装时,ppcli 要求 **Node.js >= 20**。
|
|
33
|
+
通过 npm 安装时,ppcli 要求 **Node.js >= 20.18.1**。
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
node --version
|
|
@@ -324,7 +324,7 @@ ppcli plugin uninstall my-tool # 卸载
|
|
|
324
324
|
- **返回空数据,或者报错 "Unauthorized"**
|
|
325
325
|
- Chrome/Chromium 里的登录态可能已经过期。请打开当前页面,在新标签页重新手工登录或刷新该页面。
|
|
326
326
|
- **Node API 错误 / 缺少 `fetch` / 旧 Node 启动即崩**
|
|
327
|
-
- ppcli 要求 **Node.js >= 20**。先执行 `node --version`,如果版本过低先升级,再重试命令。
|
|
327
|
+
- ppcli 要求 **Node.js >= 20.18.1**。先执行 `node --version`,如果版本过低先升级,再重试命令。
|
|
328
328
|
- **Daemon 问题**
|
|
329
329
|
- 检查 daemon 状态:`curl localhost:19825/status`
|
|
330
330
|
- 查看扩展日志:`curl localhost:19825/logs`
|
package/cli-manifest.json
CHANGED
|
@@ -6486,7 +6486,7 @@
|
|
|
6486
6486
|
"description": "BOSS直聘查看职位详情",
|
|
6487
6487
|
"access": "read",
|
|
6488
6488
|
"domain": "www.zhipin.com",
|
|
6489
|
-
"strategy": "
|
|
6489
|
+
"strategy": "ui",
|
|
6490
6490
|
"browser": true,
|
|
6491
6491
|
"args": [
|
|
6492
6492
|
{
|
|
@@ -6494,7 +6494,7 @@
|
|
|
6494
6494
|
"type": "str",
|
|
6495
6495
|
"required": true,
|
|
6496
6496
|
"positional": true,
|
|
6497
|
-
"help": "Security ID from search results (
|
|
6497
|
+
"help": "Security ID from search results (security_id field)"
|
|
6498
6498
|
}
|
|
6499
6499
|
],
|
|
6500
6500
|
"columns": [
|
|
@@ -6503,7 +6503,7 @@
|
|
|
6503
6503
|
"experience",
|
|
6504
6504
|
"degree",
|
|
6505
6505
|
"city",
|
|
6506
|
-
"
|
|
6506
|
+
"address",
|
|
6507
6507
|
"description",
|
|
6508
6508
|
"skills",
|
|
6509
6509
|
"welfare",
|
|
@@ -6514,13 +6514,14 @@
|
|
|
6514
6514
|
"industry",
|
|
6515
6515
|
"scale",
|
|
6516
6516
|
"stage",
|
|
6517
|
-
"address",
|
|
6518
6517
|
"url"
|
|
6519
6518
|
],
|
|
6520
6519
|
"type": "js",
|
|
6521
6520
|
"modulePath": "boss/detail.js",
|
|
6522
6521
|
"sourceFile": "boss/detail.js",
|
|
6523
|
-
"navigateBefore": false
|
|
6522
|
+
"navigateBefore": false,
|
|
6523
|
+
"siteSession": "persistent",
|
|
6524
|
+
"defaultWindowMode": "background"
|
|
6524
6525
|
},
|
|
6525
6526
|
{
|
|
6526
6527
|
"site": "boss",
|
|
@@ -7090,7 +7091,7 @@
|
|
|
7090
7091
|
"description": "BOSS直聘搜索职位(不带关键词时返回为你推荐职位)",
|
|
7091
7092
|
"access": "read",
|
|
7092
7093
|
"domain": "www.zhipin.com",
|
|
7093
|
-
"strategy": "
|
|
7094
|
+
"strategy": "intercept",
|
|
7094
7095
|
"browser": true,
|
|
7095
7096
|
"args": [
|
|
7096
7097
|
{
|
|
@@ -7173,7 +7174,9 @@
|
|
|
7173
7174
|
"type": "js",
|
|
7174
7175
|
"modulePath": "boss/search.js",
|
|
7175
7176
|
"sourceFile": "boss/search.js",
|
|
7176
|
-
"navigateBefore": false
|
|
7177
|
+
"navigateBefore": false,
|
|
7178
|
+
"siteSession": "persistent",
|
|
7179
|
+
"defaultWindowMode": "background"
|
|
7177
7180
|
},
|
|
7178
7181
|
{
|
|
7179
7182
|
"site": "boss",
|
|
@@ -14575,7 +14578,7 @@
|
|
|
14575
14578
|
{
|
|
14576
14579
|
"site": "douyin",
|
|
14577
14580
|
"name": "stats",
|
|
14578
|
-
"description": "
|
|
14581
|
+
"description": "获取单个作品的创作者中心深层指标",
|
|
14579
14582
|
"access": "read",
|
|
14580
14583
|
"domain": "creator.douyin.com",
|
|
14581
14584
|
"strategy": "cookie",
|
|
@@ -29058,6 +29061,62 @@
|
|
|
29058
29061
|
"sourceFile": "notebooklm/write-note.js",
|
|
29059
29062
|
"navigateBefore": false
|
|
29060
29063
|
},
|
|
29064
|
+
{
|
|
29065
|
+
"site": "nowcoder",
|
|
29066
|
+
"name": "article",
|
|
29067
|
+
"description": "发布长文文章(实名长贴,支持 Markdown 正文与图片自动转存;高风险写操作,务必低频且内容有价值)",
|
|
29068
|
+
"access": "write",
|
|
29069
|
+
"domain": "www.nowcoder.com",
|
|
29070
|
+
"strategy": "cookie",
|
|
29071
|
+
"browser": true,
|
|
29072
|
+
"args": [
|
|
29073
|
+
{
|
|
29074
|
+
"name": "title",
|
|
29075
|
+
"type": "str",
|
|
29076
|
+
"required": true,
|
|
29077
|
+
"positional": true,
|
|
29078
|
+
"help": "文章标题(不超过 50 字)"
|
|
29079
|
+
},
|
|
29080
|
+
{
|
|
29081
|
+
"name": "content",
|
|
29082
|
+
"type": "str",
|
|
29083
|
+
"required": false,
|
|
29084
|
+
"positional": true,
|
|
29085
|
+
"help": "正文(默认 Markdown,自动转 HTML;也可直接给 HTML)",
|
|
29086
|
+
"fromFile": true,
|
|
29087
|
+
"requiredUnlessFile": true
|
|
29088
|
+
},
|
|
29089
|
+
{
|
|
29090
|
+
"name": "html",
|
|
29091
|
+
"type": "boolean",
|
|
29092
|
+
"required": false,
|
|
29093
|
+
"help": "把正文按原始 HTML 处理,不做 Markdown 转换"
|
|
29094
|
+
},
|
|
29095
|
+
{
|
|
29096
|
+
"name": "allow-duplicate",
|
|
29097
|
+
"type": "boolean",
|
|
29098
|
+
"required": false,
|
|
29099
|
+
"help": "允许与自己已发文章同标题(默认拦截,防止重试导致重复发布)"
|
|
29100
|
+
},
|
|
29101
|
+
{
|
|
29102
|
+
"name": "file",
|
|
29103
|
+
"type": "str",
|
|
29104
|
+
"required": false,
|
|
29105
|
+
"help": "从本机文件读取content(UTF-8),与 <content> 二选一。长正文建议走文件:命令行上只剩短参数,不受 shell 引号 / 换行 / 编码影响",
|
|
29106
|
+
"generatedFileFor": "content"
|
|
29107
|
+
}
|
|
29108
|
+
],
|
|
29109
|
+
"columns": [
|
|
29110
|
+
"content_id",
|
|
29111
|
+
"title",
|
|
29112
|
+
"url",
|
|
29113
|
+
"images_uploaded"
|
|
29114
|
+
],
|
|
29115
|
+
"type": "js",
|
|
29116
|
+
"modulePath": "nowcoder/article.js",
|
|
29117
|
+
"sourceFile": "nowcoder/article.js",
|
|
29118
|
+
"navigateBefore": "https://www.nowcoder.com"
|
|
29119
|
+
},
|
|
29061
29120
|
{
|
|
29062
29121
|
"site": "nowcoder",
|
|
29063
29122
|
"name": "comment",
|
|
@@ -29208,7 +29267,7 @@
|
|
|
29208
29267
|
{
|
|
29209
29268
|
"site": "nowcoder",
|
|
29210
29269
|
"name": "delete",
|
|
29211
|
-
"description": "
|
|
29270
|
+
"description": "删除自己发布的动态或文章",
|
|
29212
29271
|
"access": "write",
|
|
29213
29272
|
"domain": "www.nowcoder.com",
|
|
29214
29273
|
"strategy": "cookie",
|
|
@@ -29219,11 +29278,12 @@
|
|
|
29219
29278
|
"type": "str",
|
|
29220
29279
|
"required": true,
|
|
29221
29280
|
"positional": true,
|
|
29222
|
-
"help": "要删除的
|
|
29281
|
+
"help": "要删除的 id(动态取 my/post 的 moment_id,文章取 article 的 content_id)"
|
|
29223
29282
|
}
|
|
29224
29283
|
],
|
|
29225
29284
|
"columns": [
|
|
29226
|
-
"
|
|
29285
|
+
"id",
|
|
29286
|
+
"kind",
|
|
29227
29287
|
"status"
|
|
29228
29288
|
],
|
|
29229
29289
|
"type": "js",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><title>供应链实习生_OpenCLI招聘-BOSS直聘</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<section class="job-primary">
|
|
6
|
+
<div class="name"><h1 title="供应链实习生">供应链实习生</h1><span class="salary">180-230元/天</span></div>
|
|
7
|
+
<p><a class="text-desc text-city" href="/shanghai/">上海</a><span class="text-desc text-experiece">5天/周 6个月</span><span class="text-desc text-degree">本科</span></p>
|
|
8
|
+
<div class="tag-all job-tags"><span>五险一金</span><span>餐补</span></div>
|
|
9
|
+
</section>
|
|
10
|
+
<main class="job-detail">
|
|
11
|
+
<section class="job-detail-section">
|
|
12
|
+
<ul class="job-keyword-list"><li>供应链/物流类专业</li><li>采购/供应商管理经验</li></ul>
|
|
13
|
+
<div class="job-sec-text">负责订单、仓储与采购协同,使用 Excel 制作供应链报表。</div>
|
|
14
|
+
</section>
|
|
15
|
+
<div class="job-boss-info">
|
|
16
|
+
<h2 class="name">张女士<span class="boss-active-time">本周活跃</span></h2>
|
|
17
|
+
<div class="boss-info-attr">OpenCLI<em class="vdot">·</em>供应链负责人</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="detail-section-item company-address"><h3>工作地址</h3><div class="location-address">上海示例路 8 号</div><p>点击查看地图</p></div>
|
|
20
|
+
</main>
|
|
21
|
+
<aside class="job-sider">
|
|
22
|
+
<p class="title">公司基本信息</p>
|
|
23
|
+
<a href="/gongsi/example.html" title="OpenCLI">OpenCLI</a>
|
|
24
|
+
<p>D轮及以上</p>
|
|
25
|
+
<p>1000-9999人</p>
|
|
26
|
+
<p><a href="/i101001/">互联网</a></p>
|
|
27
|
+
<a class="look-all">查看全部职位</a>
|
|
28
|
+
</aside>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
package/clis/boss/detail.js
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as A,Strategy as _}from"@jackwener/opencli/registry";import{ArgumentError as B,AuthRequiredError as q,CommandExecutionError as k}from"@jackwener/opencli/errors";import{readRequiredString as N,requirePage as C,navigateTo as j,verbose as v}from"./utils.js";const O="www.zhipin.com";function s(e){return String(e||"").replace(/\u00a0/g," ").replace(/[ \t]+\n/g,`
|
|
2
|
+
`).replace(/\n[ \t]+/g,`
|
|
3
|
+
`).replace(/\n{3,}/g,`
|
|
4
|
+
|
|
5
|
+
`).trim()}function g(e,r){if(!e||typeof e!=="object"||!s(e.jobName))return null;const o=(n)=>[...new Set(n.map(s).filter(Boolean))],i=(n)=>s(n)||null;return{name:s(e.jobName),salary:s(e.salaryText),experience:s(e.experienceText),degree:s(e.degreeText),city:i(e.cityText),address:i(e.addressText),description:s(e.descriptionText),skills:o(Array.isArray(e.skillTexts)?e.skillTexts:[]).join(", "),welfare:o(Array.isArray(e.welfareTexts)?e.welfareTexts:[]).join(", "),boss_name:i(e.recruiterName),boss_title:i(e.recruiterTitle),active_time:i(e.recruiterActiveTime),company:s(e.companyName),industry:i(e.industryText),scale:i(e.scaleText),stage:i(e.stageText),url:`https://www.zhipin.com/job_detail/${r}.html`}}export function extractRenderedJob(){const e=(t,d=document)=>{const a=d.querySelector(t);return a?(a.innerText||a.textContent||"").trim():""},r=(t,d=document)=>Array.from(d.querySelectorAll(t)).map((a)=>(a.innerText||a.textContent||"").trim()).filter(Boolean),o=(t)=>{for(const d of t){const a=e(d);if(a)return a}return""},i=o([".job-primary .name h1",".job-primary h1","h1[title]"]),n=r(".job-primary .job-limit a, .job-primary .job-limit span, .job-primary p a, .job-primary p span"),c=Array.from(document.querySelectorAll('.job-sider a[href^="/gongsi/"], .detail-op a[href*="/gongsi/"]')).map((t)=>(t.getAttribute("title")||t.innerText||t.textContent||"").trim()).filter((t)=>t&&!/查看.*职位/.test(t)),T=document.title.match(/_([^_]+)招聘-BOSS直聘$/)?.[1]||"",u=c[0]||T,b=r('.job-sider p, .job-sider a[href^="/i"]').filter((t)=>t!=="公司基本信息"&&t!=="查看全部职位"),l=document.querySelector(".job-boss-info"),m=l?.querySelector("h2"),h=m?Array.from(m.childNodes).filter((t)=>t.nodeType===3).map((t)=>t.textContent.trim()).filter(Boolean).join(" "):"",y=l?(l.innerText||l.textContent||"").split(/\n|·/).map((t)=>t.trim()).filter(Boolean):[],f=m?e("span",m):y.find((t)=>/活跃|在线/.test(t))||"",x=h||y[0]||"",w=e(".boss-info-attr",l).split("·").map((t)=>t.trim()).filter(Boolean).findLast((t)=>t!==u)||y.findLast((t)=>t!==x&&t!==f&&t!==u)||"",p=document.querySelector(".company-address"),S=e(".location-address",p)||(p?(p.innerText||p.textContent||"").split(`
|
|
6
|
+
`).map((t)=>t.trim()).filter((t)=>t&&t!=="工作地址"&&t!=="点击查看地图")[0]||"":"");return{jobName:i,salaryText:o([".job-primary .salary",".job-banner .salary",".job-primary .name + span"]),cityText:e(".job-primary .text-city")||n[0]||"",experienceText:e(".job-primary .text-experiece")||n[1]||"",degreeText:e(".job-primary .text-degree")||n[2]||"",descriptionText:o([".job-detail .job-detail-section .job-sec-text:not(.fold-text)",".job-detail .job-sec-text:not(.fold-text)",".job-sec-text:not(.fold-text)"]),skillTexts:r(".job-detail .job-keyword-list li, .job-keyword-list li"),welfareTexts:r(".job-banner .job-tags span, .job-primary .job-tags span"),recruiterName:x,recruiterTitle:w,recruiterActiveTime:f,companyName:u,industryText:b.find((t)=>!/人|融资|上市|轮/.test(t))||"",scaleText:b.find((t)=>/\d+.*人/.test(t))||"",stageText:b.find((t)=>/轮|融资|上市/.test(t))||"",addressText:S}}async function R(e,r){const o=await e.evaluate(extractRenderedJob);return g(o,r)}async function z(e,r){const o=`https://www.zhipin.com/job_detail/${encodeURIComponent(r)}.html`;await j(e,"https://www.zhipin.com/web/geek/jobs",2);for(let i=0;i<2;i++){await j(e,o,5);for(let n=0;n<5;n++){try{const c=await R(e,r);if(c?.name&&c?.description&&c?.company)return c}catch{}if(n<4)await e.wait(1)}}if(await E(e))throw new q(O,"BOSS redirected the job detail page to the login flow");throw new k("BOSS detail page did not expose a complete job posting")}async function E(e){try{return await e.evaluate(`
|
|
7
|
+
(() => {
|
|
8
|
+
const href = window.location.href || '';
|
|
9
|
+
if (/\\/login|\\/user\\/login|passport/.test(href)) return true;
|
|
10
|
+
return !!document.querySelector('.sign-form, .login-card, [class*="login-register"]');
|
|
11
|
+
})()
|
|
12
|
+
`)===!0}catch{return!1}}A({site:"boss",name:"detail",access:"read",description:"BOSS直聘查看职位详情",domain:"www.zhipin.com",strategy:_.UI,navigateBefore:!1,browser:!0,defaultWindowMode:"background",siteSession:"persistent",args:[{name:"security-id",positional:!0,required:!0,help:"Security ID from search results (security_id field)"}],columns:["name","salary","experience","degree","city","address","description","skills","welfare","boss_name","boss_title","active_time","company","industry","scale","stage","url"],func:async(e,r)=>{C(e);const o=N(r["security-id"],"security-id");if(!/^[A-Za-z0-9_-]+$/.test(o))throw new B("boss security-id contains unsupported characters","Pass the security_id returned by `opencli boss search`");v("Fetching job detail from the rendered BOSS page...");return[await z(e,o)]}});export const __test__={cleanText:s,domSnapshotToRow:g};
|
package/clis/boss/search.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as T,Strategy as B}from"@jackwener/opencli/registry";import{ArgumentError as O,CommandExecutionError as P,EmptyResultError as D}from"@jackwener/opencli/errors";import{assertOk as v,readPositiveInteger as E,requirePage as A,navigateTo as x,verbose as u,CITY_CODES as p}from"./utils.js";const N={"不限":"0","在校/应届":"108","在校生(实习)":"108","在校生":"108","在校":"108","应届生(校招)":"102","应届生":"102","应届":"102","经验不限":"101","1年以内":"103","1-3年":"104","3-5年":"105","5-10年":"106","10年以上":"107"},J={"不限":"0","初中及以下":"209","中专/中技":"208","高中":"206","大专":"202","本科":"203","硕士":"204","博士":"205"},k={"不限":"0","3K以下":"401","3-5K":"402","5-10K":"403","10-15K":"404","15-20K":"405","20-30K":"406","30-50K":"407","50K以上":"408"},M={"不限":"0","互联网":"100020","电子商务":"100021","游戏":"100024","人工智能":"100901","大数据":"100902","金融":"100101","教育培训":"100200","医疗健康":"100300"},y={"不限":"0","全职":"1901","实习":"1902","兼职":"1903"},q=new Set(Object.values(y));function _(e){if(!e)return"101010100";if(/^\d+$/.test(e))return e;if(p[e])return p[e];for(const[r,o]of Object.entries(p))if(r.includes(e))return o;throw new O(`Invalid BOSS city: ${e}`,"Use a supported city name or a numeric BOSS city code")}function d(e,r){if(!e)return"";if(r[e]!==void 0)return r[e];for(const[o,c]of Object.entries(r))if(o.includes(e))return c;return e}function K(e){if(!e)return"";if(y[e]!==void 0)return y[e];if(q.has(e))return e;throw new O(`Invalid jobType: ${e}`,"Use one of: 全职, 兼职, 实习, 不限")}function C(e){if(e===!0)return"Y";if(e===!1)return"N";return""}async function z(e,r){if(typeof e.startNetworkCapture!=="function"||typeof e.readNetworkCapture!=="function"||!await e.startNetworkCapture("joblist.json"))throw new P("BOSS search requires CDP network capture");await e.readNetworkCapture();for(let o=0;o<2;o++){const c=r.includes("?")?"&":"?";await x(e,`${r}${c}_opencli=${Date.now()}`,5);await e.wait(1);const l=await e.readNetworkCapture();for(const s of Array.isArray(l)?l:[]){if(!String(s?.url||"").includes("joblist.json")||Number(s?.responseStatus||0)!==200||typeof s?.responsePreview!=="string")continue;let n;try{n=JSON.parse(s.responsePreview)}catch{continue}if(n&&typeof n==="object"&&"code"in n&&n.code!==0)v(n,"BOSS search failed");if(Array.isArray(n?.zpData?.jobList))return n}}throw new P("BOSS search page did not expose its job-list response")}T({site:"boss",name:"search",access:"read",description:"BOSS直聘搜索职位(不带关键词时返回为你推荐职位)",domain:"www.zhipin.com",strategy:B.INTERCEPT,navigateBefore:!1,browser:!0,defaultWindowMode:"background",siteSession:"persistent",args:[{name:"query",positional:!0,help:"Search keyword (optional, empty = recommended jobs)"},{name:"city",default:"北京",help:"City name or code (e.g. 杭州, 上海, 101010100)"},{name:"experience",default:"",help:"Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上"},{name:"degree",default:"",help:"Degree: 大专/本科/硕士/博士"},{name:"salary",default:"",help:"Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上"},{name:"industry",default:"",help:"Industry code or name (e.g. 100020, 互联网)"},{name:"jobType",default:"",help:"Job type: 全职/兼职/实习(不传=不限,混合校招与实习)"},{name:"page",type:"int",default:1,help:"Page number"},{name:"limit",type:"int",default:15,help:"Number of results"}],columns:["name","salary","company","area","experience","degree","skills","boss","bossOnline","security_id","url"],func:async(e,r)=>{A(e);const o=String(r.query??"").trim(),c=_(r.city);u("Navigating to set referrer context...");const l=d(r.experience,N),s=d(r.degree,J),n=d(r.salary,k),m=d(r.industry,M),b=K(r.jobType),h=E(r.limit,"limit",15,100);let f=E(r.page,"page",1),a=[];const w=new Set;while(a.length<h){if(a.length>0)await new Promise((t)=>setTimeout(t,1000+Math.random()*2000));const i=new URLSearchParams({query:o,city:c,page:String(f)});if(l)i.set("experience",l);if(s)i.set("degree",s);if(n)i.set("salary",n);if(m)i.set("industry",m);if(b)i.set("jobType",b);const I=`https://www.zhipin.com/web/geek/jobs?${i.toString()}`;u(`Capturing page ${f}... (current jobs: ${a.length})`);const S=(await z(e,I)).zpData||{},g=S.jobList||[];if(g.length===0)break;let j=0;for(const t of g){if(!t.encryptJobId||w.has(t.encryptJobId))continue;w.add(t.encryptJobId);a.push({name:t.jobName,salary:t.salaryDesc,company:t.brandName,area:[t.cityName,t.areaDistrict,t.businessDistrict].filter(Boolean).join("·"),experience:t.jobExperience,degree:t.jobDegree,skills:(t.skills||[]).join(","),boss:t.bossName+" · "+t.bossTitle,bossOnline:C(t.bossOnline),security_id:t.encryptJobId||"",url:"https://www.zhipin.com/job_detail/"+t.encryptJobId+".html"});j++;if(a.length>=h)break}if(j===0){u(`API returned duplicate page, stopping pagination at ${a.length} items`);break}if(!S.hasMore)break;f++}if(a.length===0)throw new D("boss search",o?`No BOSS jobs found for "${o}"`:"BOSS returned no recommended jobs");return a}});export const __test__={EXP_MAP:N,resolveCity:_,resolveMap:d,resolveJobType:K,formatBossOnline:C};
|
package/clis/douyin/stats.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as l,Strategy as w}from"@jackwener/opencli/registry";import{ArgumentError as p,EmptyResultError as m}from"@jackwener/opencli/errors";import{browserFetch as y}from"./_shared/browser-fetch.js";const _="https://creator.douyin.com/web/api/creator/item/list",g=50,b=50;export function normalizeAwemeId(t){const e=String(t??"").trim();if(!/^\d{16,20}$/.test(e))throw new p("douyin stats aweme_id must be a 16-20 digit numeric ID");return e}export function sameAwemeId(t,e){if(t==null)return!1;const r=String(t);if(r===e)return!0;return/^\d+$/.test(r)&&Number(r)===Number(e)}l({site:"douyin",name:"stats",access:"read",description:"获取单个作品的创作者中心深层指标",domain:"creator.douyin.com",strategy:w.COOKIE,args:[{name:"aweme_id",required:!0,positional:!0,help:"抖音作品 ID(aweme_id,可从作品 URL 末尾获取)"}],columns:["metric","value"],func:async(t,e)=>{const r=normalizeAwemeId(e.aweme_id);let n;for(let a=0;a<b;a++){const c=new URLSearchParams({count:String(g),order_by:"1",fields:"metrics,review,visibility",need_cooperation:"true",need_long_article:"true"});if(n!==void 0)c.set("max_cursor",String(n));const s=await y(t,"GET",`${_}?${c.toString()}`),u=(s.items??[]).find((i)=>sameAwemeId(i?.id,r));if(u){const i=u.metrics;if(!i||typeof i!=="object"||Array.isArray(i))throw new m(`douyin stats ${r}`,"The work exists, but creator metrics are unavailable");return Object.entries(i).map(([d,f])=>({metric:d,value:f}))}const o=s.max_cursor;if(!s.has_more||o===void 0||o===null||n!==void 0&&String(o)===String(n))break;n=o}throw new m(`douyin stats ${r}`,"The work was not found in the logged-in creator account")}});
|
package/clis/ke/auth.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import{AuthRequiredError as o,CommandExecutionError as
|
|
1
|
+
import{AuthRequiredError as o,CommandExecutionError as s}from"@jackwener/opencli/errors";import{registerSiteAuthCommands as a}from"../_shared/site-auth.js";async function i(e){return(await e.getCookies({url:"https://www.ke.com"})).some((t)=>t.name==="lianjia_token"&&t.value)}async function r(e){if(!await i(e))throw new o("ke.com","Ke lianjia_token cookie missing — anonymous");await e.goto("https://www.ke.com/");await e.wait(2);const n=await e.evaluate(`
|
|
2
2
|
(() => {
|
|
3
3
|
const loginBtn = document.querySelector('.btn-login, a[class*=actLoginBtn], .login-btn');
|
|
4
4
|
if (loginBtn && /登录|登陆/.test(loginBtn.innerText || '')) {
|
|
5
5
|
return { kind: 'auth', detail: 'Ke shows 登录 button — anonymous session' };
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
// 2026-08 贝壳新版 SSR:用户名在 .typeShowUser(脱敏手机号如 15****93),
|
|
8
|
+
// 旧锚点 .userNick/.user-name/.myInfo 已下线,故把 .typeShowUser 提到最前。
|
|
9
|
+
const el = document.querySelector('.typeShowUser a span, .typeShowUser a, .userNick, .user-name, .myInfo a, [class*=userNick]');
|
|
8
10
|
const username = (el?.innerText || '').trim();
|
|
9
11
|
if (!username) {
|
|
10
12
|
return { kind: 'auth', detail: 'Ke no user-name DOM anchor — anonymous or SSR failed' };
|
|
11
13
|
}
|
|
12
14
|
return { ok: true, username };
|
|
13
15
|
})()
|
|
14
|
-
`);if(n?.kind==="auth")throw new o("ke.com",n.detail);if(!n?.ok)throw new
|
|
16
|
+
`);if(n?.kind==="auth")throw new o("ke.com",n.detail);if(!n?.ok)throw new s(`Unexpected Ke probe: ${JSON.stringify(n)}`);return{username:n.username}}a({site:"ke",domain:"ke.com",loginUrl:"https://clogin.ke.com/login/?service=https%3A%2F%2Fwww.ke.com",columns:["username"],verify:r,poll:async(e)=>{if(!await i(e))throw new o("ke.com","Waiting for Ke lianjia_token cookie");return r(e)}});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{cli as E,Strategy as S}from"@jackwener/opencli/registry";import{ArgumentError as i,CommandExecutionError as c}from"@jackwener/opencli/errors";import{GW_BASE as _,WWW_BASE as r,getJson as M,postJson as q,postMultipart as T,requireOk as m}from"./utils.js";import{assertLiteralContent as y}from"../_shared/content-guard.js";import{normalizeContent as A}from"../_shared/article/format.js";import{inlineLocalImages as C,transferImages as j}from"../_shared/article/images.js";const w={Accept:"application/json, text/plain, */*","X-Requested-With":"XMLHttpRequest"},L={url:`${r}/uploadImage?type=1`,method:"POST",bodyType:"binary-multipart",fileField:"file",fileName:"image.png",headers:w,responsePath:"url"},P=["uploadfiles.nowcoder.com"],g=50;function D(e){return String(e).replace(/<br\s*\/?>/gi,`
|
|
2
|
+
`).replace(/<\/(?:p|h[1-6]|li|blockquote|div)>/gi,`
|
|
3
|
+
`).replace(/<[^>]+>/g,"").replace(/ /g," ").replace(/</g,"<").replace(/>/g,">").replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,"&").split(`
|
|
4
|
+
`).map((n)=>n.trim()).filter(Boolean)}function H(e,n){const t=D(n);return JSON.stringify({editorChildren:t.map((a)=>({type:"paragraph",children:[{text:a}]})),toolData:{title:e,content:t.join(`
|
|
5
|
+
`),type:0,subjectIds:[]}})}E({site:"nowcoder",name:"article",access:"write",description:"发布长文文章(实名长贴,支持 Markdown 正文与图片自动转存;高风险写操作,务必低频且内容有价值)",domain:"www.nowcoder.com",strategy:S.COOKIE,args:[{name:"title",positional:!0,required:!0,help:`文章标题(不超过 ${g} 字)`},{name:"content",fromFile:!0,positional:!0,required:!0,help:"正文(默认 Markdown,自动转 HTML;也可直接给 HTML)"},{name:"html",type:"boolean",help:"把正文按原始 HTML 处理,不做 Markdown 转换"},{name:"allow-duplicate",type:"boolean",help:"允许与自己已发文章同标题(默认拦截,防止重试导致重复发布)"}],columns:["content_id","title","url","images_uploaded"],func:async(e,n)=>{const t=String(n.title??"").trim();y(t,{label:"标题"});if(!t)throw new i("文章标题不能为空");if([...t].length>g)throw new i(`文章标题超长: ${[...t].length} 字,牛客上限 ${g} 字`);const a=y(n.content,{label:"正文"});if(!a)throw new i("文章正文不能为空");const p=await C(a);if(p.missing.length){const s=p.missing.map((o)=>`${o.src}(${o.error})`).join("、");throw new i(`正文引用的本机图片读不到: ${s}`)}const{html:f}=A(p.content,{format:n.html?"html":"auto"});if(!f.trim())throw new i("正文转换后为空");const l=await j(e,f,{spec:L,skip:P});if(l.failed?.length){const s=l.failed.map((o)=>`${o.src}(${o.error??"上传失败"})`).join("、");throw new c(`图片转存牛客图床失败,已中止发布(未产生任何内容,可修好图片后重试): ${s}`)}const d=l.content;if(n["allow-duplicate"]!==!0){const b=(m(await M(e,`${r}/content/my-content?status=-1&page=1&_=${Date.now()}`),"我的内容列表").data?.tableData??[]).find((I)=>String(I.title??"").trim()===t);if(b)throw new c(`牛客上已有同标题的文章(${r}/discuss/${b.id}),已中止发布。`+"若上一次发布其实已成功,请不要重试;确需再发一篇同名文章加 --allow-duplicate。")}if(m(await T(e,`${r}/discuss/check/same?_=${Date.now()}`,{id:"",title:t,content:d,subjectIds:""},w),"长文查重").data===!0)throw new c("牛客站内已存在标题与正文都相同的内容,已中止发布(若上一次发布其实已成功,请勿重试;确需再发请修改内容)");const h=m(await q(e,`${_}/long-content/create?_=${Date.now()}`,{subjectIds:[],zhuanlanIdStr:"",askQuestion:!1,reward:"",tags:[],title:t,content:d,questionId:null,questionTitle:null,questionUrl:null,blogTags:[],isAcm:!1,distinctPost:!1,jsonContent:H(t,d),id:"",mdContent:"",anonymous:!1,publicEntrance:"发帖子",publicEntrancePage:"直接访问"},w),"长文发布"),u=h.data?.result;if(!u)throw new c(`牛客长文发布响应缺少 contentId: ${JSON.stringify(h).slice(0,200)}`);return[{content_id:String(u),title:t,url:`${r}/discuss/${u}`,images_uploaded:(l.uploaded??[]).length}]}});
|
package/clis/nowcoder/delete.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as u,Strategy as w}from"@jackwener/opencli/registry";import{GW_BASE as o,postMultipart as s,requireOk as a,resolveMomentId as p}from"./utils.js";const y={Accept:"application/json, text/plain, */*","X-Requested-With":"XMLHttpRequest"};u({site:"nowcoder",name:"delete",access:"write",description:"删除自己发布的动态或文章",domain:"www.nowcoder.com",strategy:w.COOKIE,args:[{name:"id",positional:!0,required:!0,help:"要删除的 id(动态取 my/post 的 moment_id,文章取 article 的 content_id)"}],columns:["id","kind","status"],func:async(r,c)=>{const t=String(c.id??"").trim(),d=/^\d{17,}$/.test(t),n=async()=>{const e=await p(r,t);a(await s(r,`${o}/moment/delete`,{isWeb:"true",momentId:e}),"删除动态");return[{id:e,kind:"moment",status:"deleted"}]},i=async()=>{a(await s(r,`${o}/long-content/delete?_=${Date.now()}`,{id:t},y),"删除文章");return[{id:t,kind:"article",status:"deleted"}]},[m,l]=d?[i,n]:[n,i];try{return await m()}catch(e){try{return await l()}catch{throw e}}}});
|
package/clis/nowcoder/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import{AuthRequiredError as
|
|
1
|
+
import{AuthRequiredError as a,CommandExecutionError as i}from"@jackwener/opencli/errors";export const GW_BASE="https://gw-c.nowcoder.com/api/sparta",WWW_BASE="https://www.nowcoder.com";export async function getJson(t,e){const s=JSON.stringify(e);return t.evaluate(`
|
|
2
2
|
async () => {
|
|
3
|
-
const res = await fetch(${
|
|
3
|
+
const res = await fetch(${s}, { credentials: 'include' });
|
|
4
4
|
const text = await res.text();
|
|
5
5
|
try {
|
|
6
6
|
return JSON.parse(text);
|
|
@@ -8,13 +8,13 @@ import{AuthRequiredError as i,CommandExecutionError as c}from"@jackwener/opencli
|
|
|
8
8
|
return { code: -1, msg: '非 JSON 响应 (HTTP ' + res.status + '): ' + text.slice(0, 200) };
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
-
`)}export async function postJson(t,e,r){const
|
|
11
|
+
`)}export async function postJson(t,e,s,r={}){const n=JSON.stringify(e),o=JSON.stringify(JSON.stringify(s)),c=JSON.stringify({"Content-Type":"application/json",...r});return t.evaluate(`
|
|
12
12
|
async () => {
|
|
13
|
-
const res = await fetch(${
|
|
13
|
+
const res = await fetch(${n}, {
|
|
14
14
|
method: 'POST',
|
|
15
15
|
credentials: 'include',
|
|
16
|
-
headers: {
|
|
17
|
-
body: ${
|
|
16
|
+
headers: ${c},
|
|
17
|
+
body: ${o},
|
|
18
18
|
});
|
|
19
19
|
const text = await res.text();
|
|
20
20
|
try {
|
|
@@ -23,10 +23,10 @@ import{AuthRequiredError as i,CommandExecutionError as c}from"@jackwener/opencli
|
|
|
23
23
|
return { code: -1, msg: '非 JSON 响应 (HTTP ' + res.status + '): ' + text.slice(0, 200) };
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
`)}export async function postForm(t,e,
|
|
26
|
+
`)}export async function postForm(t,e,s){const r=JSON.stringify(e),n=JSON.stringify(s);return t.evaluate(`
|
|
27
27
|
async () => {
|
|
28
28
|
const body = new URLSearchParams(${n});
|
|
29
|
-
const res = await fetch(${
|
|
29
|
+
const res = await fetch(${r}, {
|
|
30
30
|
method: 'POST',
|
|
31
31
|
credentials: 'include',
|
|
32
32
|
headers: {
|
|
@@ -42,13 +42,14 @@ import{AuthRequiredError as i,CommandExecutionError as c}from"@jackwener/opencli
|
|
|
42
42
|
return { code: -1, msg: '非 JSON 响应 (HTTP ' + res.status + '): ' + text.slice(0, 200) };
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
`)}export async function postMultipart(t,e,r){const
|
|
45
|
+
`)}export async function postMultipart(t,e,s,r={}){const n=JSON.stringify(e),o=JSON.stringify(s),c=JSON.stringify(r);return t.evaluate(`
|
|
46
46
|
async () => {
|
|
47
47
|
const fd = new FormData();
|
|
48
|
-
for (const [k, v] of Object.entries(${
|
|
49
|
-
const res = await fetch(${
|
|
48
|
+
for (const [k, v] of Object.entries(${o})) fd.append(k, v);
|
|
49
|
+
const res = await fetch(${n}, {
|
|
50
50
|
method: 'POST',
|
|
51
51
|
credentials: 'include',
|
|
52
|
+
headers: ${c},
|
|
52
53
|
body: fd,
|
|
53
54
|
});
|
|
54
55
|
const text = await res.text();
|
|
@@ -58,11 +59,11 @@ import{AuthRequiredError as i,CommandExecutionError as c}from"@jackwener/opencli
|
|
|
58
59
|
return { code: -1, msg: '非 JSON 响应 (HTTP ' + res.status + '): ' + text.slice(0, 200) };
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
`)}export function requireOk(t,e){if(!t||typeof t!=="object"||Array.isArray(t))throw new
|
|
62
|
+
`)}export function requireOk(t,e){if(!t||typeof t!=="object"||Array.isArray(t))throw new i(`牛客 ${e} 接口返回了畸形响应`);if(Object.hasOwn(t,"success")?!!t.success:Object.hasOwn(t,"code")?t.code===0:Object.hasOwn(t,"data"))return t;const r=String(t.msg??t.message??"未知错误");if(/登录|登陆|未授权|token|身份/i.test(r))throw new a("nowcoder.com",`牛客 ${e} 需要登录: ${r}`);throw new i(`牛客 ${e} 失败: ${r} (code=${t.code??"?"})`)}export async function resolveMomentId(t,e){let s=String(e??"").trim();for(const o of[/detail\/([0-9a-fA-F]{20,})/,/discuss\/(\d+)/]){const c=s.match(o);if(c){s=c[1];break}}if(/^\d+$/.test(s))return s;if(!/^[0-9a-fA-F]{20,}$/.test(s))throw new i(`无法从输入解析牛客帖子 id: ${e}`);const n=requireOk(await getJson(t,`${GW_BASE}/detail/moment-data/detail/${s}`),"帖子解析").data?.id;if(!n)throw new i(`牛客帖子 ${s} 的详情缺少数字 momentId`);return String(n)}export async function getSelfUid(t){const e=await t.evaluate(`
|
|
62
63
|
async () => {
|
|
63
64
|
const link = document.querySelector('a[href*="/users/"]');
|
|
64
65
|
const href = link ? (link.getAttribute('href') || '') : '';
|
|
65
66
|
const match = href.match(/\\/users\\/(\\d+)/);
|
|
66
67
|
return match ? match[1] : '';
|
|
67
68
|
}
|
|
68
|
-
`);if(!e)throw new
|
|
69
|
+
`);if(!e)throw new a("nowcoder.com","导航栏无登录用户链接(未登录)");return e}
|
package/clis/spotify/spotify.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as i,Strategy as c}from"@jackwener/opencli/registry";import{CliError as u}from"@jackwener/opencli/errors";import{readFileSync as
|
|
1
|
+
import{cli as i,Strategy as c}from"@jackwener/opencli/registry";import{CliError as u}from"@jackwener/opencli/errors";import{readFileSync as _,writeFileSync as I,mkdirSync as R,existsSync as C}from"fs";import{createServer as v}from"http";import{homedir as m}from"os";import{join as y}from"path";import{execFile as d}from"child_process";import{assertSpotifyCredentialsConfigured as L,getFirstSpotifyTrack as x,mapSpotifyTrackResults as A,parseDotEnv as q,resolveSpotifyCredentials as B}from"./utils.js";const h=y(m(),".ppcli","spotify.env");function O(){if(!C(h))return{};return q(_(h,"utf-8"))}const N=O(),w=B(N),k=w.clientId,T=w.clientSecret,S="http://127.0.0.1:8888/callback",D=["user-read-playback-state","user-modify-playback-state","user-read-currently-playing","playlist-read-private"].join(" "),U=y(m(),".ppcli","spotify-tokens.json");function F(){try{return JSON.parse(_(U,"utf-8"))}catch{return null}}function P(t){R(y(m(),".ppcli"),{recursive:!0});I(U,JSON.stringify(t,null,2))}async function M(t){const e=await fetch("https://accounts.spotify.com/api/token",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Authorization:"Basic "+Buffer.from(`${k}:${T}`).toString("base64")},body:new URLSearchParams({grant_type:"refresh_token",refresh_token:t})});if(!e.ok){const r=await e.json().catch(()=>({}));throw new u("REFRESH_FAILED",r?.error_description||`Token refresh failed (${e.status})`)}const s=await e.json(),a={access_token:s.access_token,refresh_token:s.refresh_token||t,expires_at:Date.now()+s.expires_in*1000};P(a);return a.access_token}async function j(){const t=F();if(!t)throw new u("AUTH_REQUIRED","Not authenticated. Run: ppcli spotify auth");if(!t.access_token||!t.refresh_token||!(t.expires_at>0))throw new u("AUTH_CORRUPTED","Token file is corrupted. Run: ppcli spotify auth");if(Date.now()>t.expires_at-60000)return M(t.refresh_token);return t.access_token}async function o(t,e,s){const a=await j(),r=await fetch(`https://api.spotify.com/v1${e}`,{method:t,headers:{Authorization:`Bearer ${a}`,"Content-Type":"application/json"},body:s?JSON.stringify(s):void 0});if(r.status===204||r.status===202)return null;if(!r.ok){const n=await r.json().catch(()=>({}));throw new u("API_ERROR",n?.error?.message||`Spotify API error ${r.status}`)}return r.json()}async function b(t){const e=await o("GET",`/search?q=${encodeURIComponent(t)}&type=track&limit=1`),s=x(e);if(!s)throw new u("EMPTY_RESULT",`No track found for: ${t}`);return s}function H(t){if(process.platform==="win32")d("cmd",["/c","start","",t]);else if(process.platform==="darwin")d("open",[t]);else d("xdg-open",[t])}i({site:"spotify",name:"auth",access:"write",description:"Authenticate with Spotify (OAuth — run once)",strategy:c.PUBLIC,browser:!1,args:[],columns:["status"],func:async()=>{L(w,h);return new Promise((t,e)=>{const s=v(async(r,n)=>{try{const p=new URL(r.url,"http://localhost:8888");if(p.pathname!=="/callback"){n.end();return}const g=p.searchParams.get("code");if(!g){n.end("Missing code");return}const l=await fetch("https://accounts.spotify.com/api/token",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",Authorization:"Basic "+Buffer.from(`${k}:${T}`).toString("base64")},body:new URLSearchParams({grant_type:"authorization_code",code:g,redirect_uri:S})});if(!l.ok){const E=await l.json().catch(()=>({}));s.close();e(new u("AUTH_FAILED",E?.error_description||`Token exchange failed (${l.status})`));return}const f=await l.json();P({access_token:f.access_token,refresh_token:f.refresh_token,expires_at:Date.now()+f.expires_in*1000});n.writeHead(200,{"Content-Type":"text/html"});n.end("<h2>Spotify authenticated! You can close this tab.</h2>");s.close();t([{status:"Authenticated successfully"}])}catch(p){s.close();e(p)}});s.on("error",(r)=>{if(r.code==="EADDRINUSE")e(new u("PORT_IN_USE","Port 8888 is already in use. Stop the other process and retry."));else e(r)});const a=setTimeout(()=>{s.close();e(new u("AUTH_TIMEOUT","Authentication timed out after 5 minutes"))},300000);s.listen(8888,()=>{const r=`https://accounts.spotify.com/authorize?${new URLSearchParams({client_id:k,response_type:"code",redirect_uri:S,scope:D})}`;console.log("Opening browser for Spotify login...");console.log("If it does not open, visit:",r);H(r)});s.on("close",()=>clearTimeout(a))})}});i({site:"spotify",name:"status",access:"read",description:"Show current playback status",strategy:c.PUBLIC,browser:!1,args:[],columns:["track","artist","album","status","progress"],func:async()=>{const t=await o("GET","/me/player");if(!t||!t.item)return[{track:"Nothing playing",artist:"",album:"",status:"",progress:""}];const e=t.item;if(e.type!=="track")return[{track:e.name,artist:"",album:e.show?.name??"",status:t.is_playing?"playing":"paused",progress:""}];const s=(t.progress_ms??0)/1000|0,a=e.duration_ms/1000|0,r=(n)=>`${n/60|0}:${String(n%60).padStart(2,"0")}`;return[{track:e.name,artist:e.artists.map((n)=>n.name).join(", "),album:e.album.name,status:t.is_playing?"playing":"paused",progress:`${r(s)} / ${r(a)}`}]}});i({site:"spotify",name:"play",access:"write",description:"Resume playback or search and play a track/artist",strategy:c.PUBLIC,browser:!1,args:[{name:"query",type:"str",default:"",positional:!0,help:"Track or artist to play (optional)"}],columns:["track","artist","status"],func:async(t)=>{if(t.query){const{uri:e,name:s,artist:a}=await b(t.query);await o("PUT","/me/player/play",{uris:[e]});return[{track:s,artist:a,status:"playing"}]}await o("PUT","/me/player/play");return[{track:"",artist:"",status:"resumed"}]}});i({site:"spotify",name:"pause",access:"write",description:"Pause playback",strategy:c.PUBLIC,browser:!1,args:[],columns:["status"],func:async()=>{await o("PUT","/me/player/pause");return[{status:"paused"}]}});i({site:"spotify",name:"next",access:"write",description:"Skip to next track",strategy:c.PUBLIC,browser:!1,args:[],columns:["status"],func:async()=>{await o("POST","/me/player/next");return[{status:"skipped to next"}]}});i({site:"spotify",name:"prev",access:"write",description:"Skip to previous track",strategy:c.PUBLIC,browser:!1,args:[],columns:["status"],func:async()=>{await o("POST","/me/player/previous");return[{status:"skipped to previous"}]}});i({site:"spotify",name:"volume",access:"write",description:"Set playback volume (0-100)",strategy:c.PUBLIC,browser:!1,args:[{name:"level",type:"int",default:50,positional:!0,required:!0,help:"Volume 0–100"}],columns:["volume"],func:async(t)=>{const e=Math.round(t.level);if(e<0||e>100)throw new u("INVALID_ARGS","Volume must be between 0 and 100");await o("PUT",`/me/player/volume?volume_percent=${e}`);return[{volume:`${e}%`}]}});i({site:"spotify",name:"search",access:"read",description:"Search for tracks",strategy:c.PUBLIC,browser:!1,args:[{name:"query",type:"str",required:!0,positional:!0,help:"Search query"},{name:"limit",type:"int",default:10,help:"Number of results (default: 10)"}],columns:["track","artist","album","uri"],func:async(t)=>{const e=Math.min(50,Math.max(1,Math.round(t.limit))),s=await o("GET",`/search?q=${encodeURIComponent(t.query)}&type=track&limit=${e}`),a=A(s);if(!a.length)throw new u("EMPTY_RESULT",`No results found for: ${t.query}`);return a}});i({site:"spotify",name:"queue",access:"write",description:"Add a track to the playback queue",strategy:c.PUBLIC,browser:!1,args:[{name:"query",type:"str",required:!0,positional:!0,help:"Track to add to queue"}],columns:["track","artist","status"],func:async(t)=>{const{uri:e,name:s,artist:a}=await b(t.query);await o("POST",`/me/player/queue?uri=${encodeURIComponent(e)}`);return[{track:s,artist:a,status:"added to queue"}]}});i({site:"spotify",name:"shuffle",access:"write",description:"Toggle shuffle on/off",strategy:c.PUBLIC,browser:!1,args:[{name:"state",type:"str",default:"on",positional:!0,choices:["on","off"],help:"on or off"}],columns:["shuffle"],func:async(t)=>{await o("PUT",`/me/player/shuffle?state=${t.state==="on"}`);return[{shuffle:t.state}]}});i({site:"spotify",name:"repeat",access:"write",description:"Set repeat mode (off / track / context)",strategy:c.PUBLIC,browser:!1,args:[{name:"mode",type:"str",default:"context",positional:!0,choices:["off","track","context"],help:"off / track / context"}],columns:["repeat"],func:async(t)=>{await o("PUT",`/me/player/repeat?state=${t.mode}`);return[{repeat:t.mode}]}});
|
package/clis/xiaohongshu/ask.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import{cli as
|
|
2
|
-
`).trim()}function a(e){return l(e).replace(/\s+/g," ").trim()}function f(...e){for(const t of e){const
|
|
3
|
-
`)}export function buildAskResult(e){const
|
|
1
|
+
import{cli as _,Strategy as x}from"@jackwener/opencli/registry";import{ArgumentError as m,AuthRequiredError as b,CommandExecutionError as u,TimeoutError as S}from"@jackwener/opencli/errors";const c="www.xiaohongshu.com",I=["query","answer","source_count","source_total_text","sources_summary","sources","warning","message_id","conversation_id"];export function unwrapEvaluateResult(e){if(e&&!Array.isArray(e)&&typeof e==="object"&&"session"in e&&"data"in e)return e.data;return e}function w(e,t,n,r,i){const o=e??t;let s;if(typeof o==="number")s=o;else if(typeof o==="string"&&/^\d+$/.test(o))s=Number(o);else throw new m(`${i} must be an integer between ${n} and ${r}, got ${JSON.stringify(e)}`);if(!Number.isInteger(s)||s<n||s>r)throw new m(`${i} must be an integer between ${n} and ${r}, got ${JSON.stringify(e)}`);return s}export function parseAskTimeout(e){return w(e,90,1,180,"--timeout")}export function parseAskLimit(e){return w(e,10,1,50,"--source-limit")}function l(e){return String(e??"").replace(/<[^>]+>/g,"").replace(/\u200b/g,"").replace(/\r\n/g,`
|
|
2
|
+
`).trim()}function a(e){return l(e).replace(/\s+/g," ").trim()}function f(...e){for(const t of e){const n=a(t);if(n)return n}return""}function v(e){if(e===null||e===void 0||e==="")return null;const t=(o)=>Number.isSafeInteger(o)&&o>=0?o:null;if(typeof e==="number")return t(e);const n=String(e).trim(),r=n.match(/^(\d+(?:\.\d+)?)\s*(万|[wW]|亿)\+?$/);if(r){const o=Number(r[1]),s=r[2]==="亿"?1e8:1e4;return Number.isFinite(o)?t(Math.round(o*s)):null}const i=n.replace(/,/g,"").replace(/\+$/,"");if(/^\d+$/.test(i)&&(n===i||n===`${i}+`||/^\d{1,3}(?:,\d{3})+\+?$/.test(n)))return t(Number(i));return null}function h(e){if(!e)return null;try{return new URL(String(e))}catch{return null}}function y(e){const t=String(e||"").trim();if(!t)return null;const n=t.match(/^xhsdiscover:\/\/item\/([0-9a-f]{24})(?=[?#/]|$)/i);if(n){const s=h(t);return{noteId:n[1],xsecToken:s?.searchParams.get("xsec_token")||s?.searchParams.get("xsecToken")||"",href:t}}let r=h(t);if(!r&&t.startsWith("/"))r=h(`https://${c}${t}`);const i=r?.hostname.toLowerCase();if(!r||r.protocol!=="https:"||i!==c&&i!=="xiaohongshu.com")return null;const o=r.pathname.match(/^\/(?:explore|search_result|note)\/([0-9a-f]{24})\/?$/i);if(!o)return null;return{noteId:o[1],xsecToken:r.searchParams.get("xsec_token")||r.searchParams.get("xsecToken")||"",href:r.toString()}}function A(e){const t=a(e?.id||e?.noteId||e?.note_id);if(/^[0-9a-f]{24}$/i.test(t))return t;const n=[e?.textLink,e?.link,e?.url];for(const r of n){const i=y(r);if(i?.noteId)return i.noteId}return""}export function buildNoteUrl(e,t){if(!/^[0-9a-f]{24}$/i.test(String(e||"")))return"";const n=new URL(`https://${c}/explore/${e}`);if(t){n.searchParams.set("xsec_token",t);n.searchParams.set("xsec_source","")}return n.toString()}function q(e){const t=l(e?.content);if(!t)return"";const n=Array.isArray(e?.originLocation)?e.originLocation:[],r=Number(n[0]),i=Number(n[1]);if(Number.isInteger(r)&&r>=0&&Number.isInteger(i)&&i>0)return a(t.slice(r,r+i)).slice(0,300);return a(t).slice(0,300)}export function normalizeAskSource(e,t){const n=[e?.textLink,e?.link,e?.url].map(y).find(Boolean),r=A(e),i=n?.xsecToken||"",o={rank:t+1,type:"note",title:f(e?.title,e?.displayTitle,e?.name),url:buildNoteUrl(r,i),note_id:r,xsec_token:i,author:f(e?.nickName,e?.nickname,e?.author,e?.userName)},s=f(e?.noteType);if(s)o.note_type=s;const d=v(e?.like);if(d!==null)o.like_count=d;const p=a(e?.userId);if(p)o.user_id=p;const g=a(e?.time);if(g)o.published_at=g;const k=q(e);if(k)o.quote=k;if(n?.href)o.deeplink=n.href;return o}function C(e){return e.slice(0,5).map((t)=>`${t.rank}. ${t.title||t.note_id}${t.author?` - ${t.author}`:""}`).join(`
|
|
3
|
+
`)}export function buildAskResult(e){const n=(Array.isArray(e?.sources)?e.sources:[]).map((s,d)=>normalizeAskSource(s,d)).filter((s)=>s.note_id||s.title||s.url),r=l(e?.answer||e?.base_info?.text||""),i=a(e?.warning),o=n.length===0&&r?`Xiaohongshu 点点 returned an answer but no citation sources.${i?` ${i}`:""}`:i;return{query:a(e?.query),answer:r,source_count:n.length,source_total_text:a(e?.source_total_text),sources_summary:C(n),sources:n,warning:o,message_id:a(e?.message_id),conversation_id:a(e?.conversation_id)}}export function buildAskEvaluateJs(e,t,n){const r=String(e);return`
|
|
4
4
|
(async () => {
|
|
5
5
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
6
|
const timeoutMs = ${Number(t)} * 1000;
|
|
7
|
-
const sourceLimit = ${Number(
|
|
7
|
+
const sourceLimit = ${Number(n)};
|
|
8
8
|
const requestSourceSize = Math.max(sourceLimit, 10);
|
|
9
|
-
const prompt = ${JSON.stringify(
|
|
9
|
+
const prompt = ${JSON.stringify(r)};
|
|
10
10
|
const simplifyText = (value, max = 6000) => {
|
|
11
11
|
const text = String(value || '');
|
|
12
12
|
return text.length > max ? text.slice(0, max) : text;
|
|
@@ -39,7 +39,41 @@ import{cli as y,Strategy as x}from"@jackwener/opencli/registry";import{ArgumentE
|
|
|
39
39
|
let webpackRequire;
|
|
40
40
|
window.webpackChunkxhs_pc_web.push([[Date.now()], {}, (req) => { webpackRequire = req; }]);
|
|
41
41
|
if (!webpackRequire) return { ok: false, error: 'webpack_require_unavailable', page_url: location.href };
|
|
42
|
-
|
|
42
|
+
// Locate the conversation store module.
|
|
43
|
+
//
|
|
44
|
+
// A hardcoded webpack module id has no contract with the site: Xiaohongshu
|
|
45
|
+
// renumbers chunks on redeploys, and when it does, webpackRequire(<stale id>)
|
|
46
|
+
// throws "Cannot read properties of undefined (reading 'call')" from webpack's
|
|
47
|
+
// own runtime and every ask fails. That already happened once (6404 -> 32914).
|
|
48
|
+
//
|
|
49
|
+
// So: try known ids first (zero scan cost in the common case), then fall back
|
|
50
|
+
// to locating the module by what it *is* rather than by its number. The scan
|
|
51
|
+
// only calls .toString() on factory functions, which has no side effects; a
|
|
52
|
+
// candidate is executed only after its source matches the fingerprint.
|
|
53
|
+
const looksLikeConversationModule = (candidate) => (
|
|
54
|
+
candidate
|
|
55
|
+
&& typeof candidate.t === 'function'
|
|
56
|
+
&& candidate.G
|
|
57
|
+
&& typeof candidate.G.AiChat === 'string'
|
|
58
|
+
);
|
|
59
|
+
let mod = null;
|
|
60
|
+
for (const knownId of [32914, 6404]) {
|
|
61
|
+
try {
|
|
62
|
+
const candidate = webpackRequire(knownId);
|
|
63
|
+
if (looksLikeConversationModule(candidate)) { mod = candidate; break; }
|
|
64
|
+
} catch { /* id absent from this build */ }
|
|
65
|
+
}
|
|
66
|
+
if (!mod) {
|
|
67
|
+
for (const id of Object.keys(webpackRequire.m || {})) {
|
|
68
|
+
let source = '';
|
|
69
|
+
try { source = webpackRequire.m[id].toString(); } catch { continue; }
|
|
70
|
+
if (!source.includes('createConversation') || !source.includes('sendMessage')) continue;
|
|
71
|
+
try {
|
|
72
|
+
const candidate = webpackRequire(id);
|
|
73
|
+
if (looksLikeConversationModule(candidate)) { mod = candidate; break; }
|
|
74
|
+
} catch { /* not loadable, keep looking */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
43
77
|
const useConversationStore = mod?.t;
|
|
44
78
|
const scenes = mod?.G || { AiChat: 'aiChat' };
|
|
45
79
|
if (typeof useConversationStore !== 'function') {
|
|
@@ -127,4 +161,4 @@ import{cli as y,Strategy as x}from"@jackwener/opencli/registry";import{ArgumentE
|
|
|
127
161
|
};
|
|
128
162
|
}
|
|
129
163
|
})()
|
|
130
|
-
`}function
|
|
164
|
+
`}function L(e){const t=String(e||"").trim();if(!t)throw new m("query is required");return t}function M(e,t){const n=a(e?.error);if(n==="answer_timeout")throw new S("xiaohongshu ask",t,"点点没有在超时时间内返回答案;可以重试或提高 --timeout。");if(n==="send_message_failed")throw new b(c,"Xiaohongshu 点点 did not accept the query. Check login status for www.xiaohongshu.com.");throw new u(`xiaohongshu ask failed: ${n||"unknown error"}`,e?.page_url?`Page URL: ${e.page_url}`:void 0)}function N(e){if(!e||typeof e!=="object")throw new u("xiaohongshu ask returned a malformed page payload");if(!l(e.answer||e.base_info?.text||""))throw new u("xiaohongshu ask returned a malformed page payload: missing answer");if(!a(e.message_id)||!a(e.conversation_id))throw new u("xiaohongshu ask returned a malformed page payload: missing message identity")}export const command=_({site:"xiaohongshu",name:"ask",access:"write",description:"Ask 小红书点点 and return the answer with citation sources.",domain:c,strategy:x.COOKIE,browser:!0,navigateBefore:!1,args:[{name:"query",positional:!0,required:!0,help:"Question for 点点"},{name:"timeout",type:"int",default:90,help:"Seconds to wait for the 点点 answer"},{name:"source-limit",type:"int",default:10,help:"Maximum citation sources to return"}],columns:I,func:async(e,t)=>{const n=L(t?.query),r=parseAskTimeout(t?.timeout),i=parseAskLimit(t?.["source-limit"]);await e.goto(`https://${c}/ai_chat`);await e.wait?.(1);const o=unwrapEvaluateResult(await e.evaluate(buildAskEvaluateJs(n,r,i)));if(!o||typeof o!=="object")throw new u("xiaohongshu ask returned a malformed page payload");if(o.ok===!1)M(o,r);N(o);return buildAskResult(o)}});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{cli as p,Strategy as d}from"@jackwener/opencli/registry";import{AuthRequiredError as
|
|
1
|
+
import{cli as p,Strategy as d}from"@jackwener/opencli/registry";import{AuthRequiredError as g,CommandExecutionError as a,EmptyResultError as w}from"@jackwener/opencli/errors";import{parseNoteId as y,buildNoteUrl as x}from"./note-helpers.js";import{readXhsDetailPage as b}from"./risk-control.js";const R='.author-wrapper a[href*="/user/profile/"], a.name[href*="/user/profile/"], a.user-name[href*="/user/profile/"], a[href*="/user/profile/"]';export function parseXhsProfileHref(r,t="www.xiaohongshu.com"){const e=typeof r==="string"?r.trim():"";if(!e)return"";const s=String(t||"www.xiaohongshu.com").toLowerCase();let o;try{o=new URL(e,`https://${s}`)}catch{return""}if(o.protocol!=="https:")return"";if(o.hostname.toLowerCase()!==s)return"";return o.pathname.match(/^\/user\/profile\/([a-zA-Z0-9]+)\/?$/)?.[1]??""}export function buildXhsProfileUrl(r,t="www.xiaohongshu.com"){const e=parseXhsProfileHref(r,t);if(!e)return"";return`https://${t}/user/profile/${e}`}export function parseCommentLimit(r,t=20){const e=Number(r);if(!Number.isFinite(e))return t;return Math.max(1,Math.min(Math.floor(e),50))}export function parseXhsLikeCountText(r){const t=/^(?:\d+|\d{1,3}(?:[,,]\d{3})+)\+?$/u,e=/^((?:\d+|\d{1,3}(?:[,,]\d{3})+)(?:\.\d+)?)([wWkK万千])\+?$/u,s=String(r??"").replace(/\s+/g,"");if(!s)return 0;if(t.test(s))return Number(s.replace(/[,+,]/g,""));const o=s.match(e);if(!o)return 0;const n=Number(o[1].replace(/[,,]/g,""));if(!Number.isFinite(n))return 0;const l=o[2].toLowerCase();return Math.round(n*(l==="w"||l==="万"?1e4:1000))}function c(r,t,e){if(r==null)return"";if(typeof r!=="string")throw new a(`${e}: malformed comment row ${t}`);return r}export function normalizeCommentImages(r,t){if(r==null)return[];if(!Array.isArray(r))throw new a(`${t}: malformed comment row images`);const e=[];for(const s of r){if(typeof s!=="string")throw new a(`${t}: malformed comment row image URL`);const o=s.trim();let n;try{n=new URL(o)}catch{throw new a(`${t}: malformed comment row image URL`)}if(n.protocol!=="https:"&&n.protocol!=="http:"||n.username||n.password)throw new a(`${t}: malformed comment row image URL`);const l=n.toString();if(!e.includes(l))e.push(l)}return e}export function normalizeCommentRows(r,t="xiaohongshu/comments"){if(r==null)return[];if(!Array.isArray(r))throw new a(`${t}: malformed comments payload`);return r.map((e,s)=>{if(!e||typeof e!=="object"||Array.isArray(e))throw new a(`${t}: malformed comment row at index ${s}`);const o=c(e.text,"text",t);if(!o)throw new a(`${t}: malformed comment row text`);const n=Number(e.likes);if(!Number.isInteger(n)||n<0)throw new a(`${t}: malformed comment row likes`);if(typeof e.is_reply!=="boolean")throw new a(`${t}: malformed comment row is_reply`);return{author:c(e.author,"author",t),authorHrefRaw:c(e.authorHrefRaw,"authorHrefRaw",t),text:o,likes:n,time:c(e.time,"time",t),is_reply:e.is_reply,reply_to:c(e.reply_to,"reply_to",t),images:normalizeCommentImages(e.images,t)}})}export function buildCommentsExtractJs(r,t=20){const e=parseXhsLikeCountText.toString();return`
|
|
2
2
|
(async () => {
|
|
3
3
|
const wait = (ms) => new Promise(r => setTimeout(r, ms))
|
|
4
4
|
const withReplies = ${r}
|
|
@@ -52,7 +52,7 @@ import{cli as p,Strategy as d}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
52
52
|
const parseLikes = (el) => {
|
|
53
53
|
return parseLikeCountText(clean(el))
|
|
54
54
|
}
|
|
55
|
-
const HREF_SELECTOR = ${JSON.stringify(
|
|
55
|
+
const HREF_SELECTOR = ${JSON.stringify(R)}
|
|
56
56
|
const extractAuthorHref = (el) => {
|
|
57
57
|
if (!el) return ''
|
|
58
58
|
const anchor = el.querySelector(HREF_SELECTOR)
|
|
@@ -129,4 +129,4 @@ import{cli as p,Strategy as d}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
129
129
|
|
|
130
130
|
return { pageUrl: location.href, securityBlock, loginWall, results }
|
|
131
131
|
})()
|
|
132
|
-
`}export const command=p({site:"xiaohongshu",name:"comments",access:"read",description:"获取小红书笔记评论(支持楼中楼子回复)",domain:"www.xiaohongshu.com",strategy:d.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(r,t)=>{const e=parseCommentLimit(t.limit),s=Boolean(t["with-replies"]),o=String(t["note-id"]),n=
|
|
132
|
+
`}export const command=p({site:"xiaohongshu",name:"comments",access:"read",description:"获取小红书笔记评论(支持楼中楼子回复)",domain:"www.xiaohongshu.com",strategy:d.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(r,t)=>{const e=parseCommentLimit(t.limit),s=Boolean(t["with-replies"]),o=String(t["note-id"]),n=y(o),l=await b(r,{url:x(o,{commandName:"xiaohongshu comments"}),extractJs:buildCommentsExtractJs(s,e),securityHelp:/^https?:\/\//.test(o)?"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(!l||typeof l!=="object")throw new w("xiaohongshu/comments","Unexpected evaluate response");if(l.loginWall)throw new g("www.xiaohongshu.com","Note comments require login");const m=normalizeCommentRows(l.results,"xiaohongshu/comments"),f=(i,u)=>({rank:u+1,author:i.author,userId:i.authorHrefRaw?parseXhsProfileHref(i.authorHrefRaw):"",profileUrl:i.authorHrefRaw?buildXhsProfileUrl(i.authorHrefRaw):"",text:i.text,likes:i.likes,time:i.time,is_reply:i.is_reply,reply_to:i.reply_to,images:i.images??[]});if(s){const i=[];let u=0;for(const h of m){if(!h.is_reply)u++;if(u>e)break;i.push(h)}return i.map(f)}return m.slice(0,e).map(f)}});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{cli as c,Strategy as l}from"@jackwener/opencli/registry";import{formatCookieHeader as d}from"@jackwener/opencli/download";import{downloadMedia as u}from"@jackwener/opencli/download/media-download";import{
|
|
1
|
+
import{cli as c,Strategy as l}from"@jackwener/opencli/registry";import{formatCookieHeader as d}from"@jackwener/opencli/download";import{downloadMedia as u}from"@jackwener/opencli/download/media-download";import{CommandExecutionError as m,EmptyResultError as h}from"@jackwener/opencli/errors";import{readXhsDetailPage as f}from"./risk-control.js";import{buildNoteUrl as p,parseNoteId as g}from"./note-helpers.js";export function buildDownloadExtractJs(t){return`
|
|
2
2
|
(() => {
|
|
3
3
|
const bodyText = document.body?.innerText || '';
|
|
4
4
|
const result = {
|
|
@@ -197,4 +197,4 @@ import{cli as c,Strategy as l}from"@jackwener/opencli/registry";import{formatCoo
|
|
|
197
197
|
|
|
198
198
|
return result;
|
|
199
199
|
})()
|
|
200
|
-
`}export const command=c({site:"xiaohongshu",name:"download",access:"read",description:"下载小红书笔记中的图片和视频",domain:"www.xiaohongshu.com",strategy:l.COOKIE,navigateBefore:!1,args:[{name:"note-id",positional:!0,required:!0,help:"Full Xiaohongshu note URL with xsec_token, or xhslink short link"},{name:"output",default:"./xiaohongshu-downloads",help:"Output directory"}],columns:["index","type","status","size"],func:async(t,r)=>{const o=String(r["note-id"]),i=r.output,s=g(o)
|
|
200
|
+
`}export const command=c({site:"xiaohongshu",name:"download",access:"read",description:"下载小红书笔记中的图片和视频",domain:"www.xiaohongshu.com",strategy:l.COOKIE,navigateBefore:!1,args:[{name:"note-id",positional:!0,required:!0,help:"Full Xiaohongshu note URL with xsec_token, or xhslink short link"},{name:"output",default:"./xiaohongshu-downloads",help:"Output directory"}],columns:["index","type","status","size"],func:async(t,r)=>{const o=String(r["note-id"]),i=r.output,s=g(o),e=await f(t,{url:p(o,{allowShortLink:!0,commandName:"xiaohongshu download"}),extractJs:buildDownloadExtractJs(s),securityHelp:/^https?:\/\//.test(o)?"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.",settleMinS:1,settleMaxS:3});if(!e||typeof e!=="object"||!Array.isArray(e.media))throw new m("Xiaohongshu media extraction returned malformed payload.");if(e.media.length===0)throw new h("xiaohongshu download","No downloadable media found on this note.");const n=d(await t.getCookies({domain:"xiaohongshu.com"})),a=typeof e.noteId==="string"&&e.noteId.trim()?e.noteId.trim():s;return u(e.media,{output:i,subdir:a,cookies:n,filenamePrefix:a,timeout:60000})}});
|
package/clis/xiaohongshu/note.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{cli as d,Strategy as h}from"@jackwener/opencli/registry";import{AuthRequiredError as m,
|
|
1
|
+
import{cli as d,Strategy as h}from"@jackwener/opencli/registry";import{AuthRequiredError as m,EmptyResultError as r}from"@jackwener/opencli/errors";import{parseNoteId as p,buildNoteUrl as f}from"./note-helpers.js";import{readXhsDetailPage as g}from"./risk-control.js";export const NOTE_EXTRACT_JS=`
|
|
2
2
|
(() => {
|
|
3
3
|
const bodyText = document.body?.innerText || ''
|
|
4
4
|
const loginWall = /登录后查看|请登录/.test(bodyText)
|
|
@@ -8,9 +8,24 @@ import{cli as d,Strategy as h}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
8
8
|
|
|
9
9
|
const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// Scope the note's own fields to #noteContainer — the detail panel.
|
|
12
|
+
// The page also renders a recommendation feed next to the note, and
|
|
13
|
+
// every card in that feed carries a .title. querySelector returns the
|
|
14
|
+
// FIRST match in document order, so for a note that has no title of
|
|
15
|
+
// its own (#detail-title absent) the unscoped selector fell through to
|
|
16
|
+
// .title and reported an unrelated recommendation card's title as this
|
|
17
|
+
// note's title. Same class of bug as the .interact-container scoping
|
|
18
|
+
// below.
|
|
19
|
+
const scope = document.querySelector('#noteContainer')
|
|
20
|
+
const title = scope
|
|
21
|
+
? clean(scope.querySelector('#detail-title, .title'))
|
|
22
|
+
: clean(document.querySelector('#detail-title'))
|
|
23
|
+
const desc = scope
|
|
24
|
+
? clean(scope.querySelector('#detail-desc, .desc, .note-text'))
|
|
25
|
+
: clean(document.querySelector('#detail-desc, .note-text'))
|
|
26
|
+
const author = scope
|
|
27
|
+
? clean(scope.querySelector('.username, .author-wrapper .name'))
|
|
28
|
+
: clean(document.querySelector('.username, .author-wrapper .name'))
|
|
14
29
|
// Scope to .interact-container — the post's main interaction bar.
|
|
15
30
|
// Without scoping, .like-wrapper / .chat-wrapper also match each
|
|
16
31
|
// comment's like/reply buttons in the comment section, and
|
|
@@ -29,4 +44,4 @@ import{cli as d,Strategy as h}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
29
44
|
|
|
30
45
|
return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
|
|
31
46
|
})()
|
|
32
|
-
`,command=d({site:"xiaohongshu",name:"note",access:"read",description:"获取小红书笔记正文和互动数据",domain:"www.xiaohongshu.com",strategy:h.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full Xiaohongshu note URL with xsec_token"}],columns:["field","value"],func:async(
|
|
47
|
+
`,command=d({site:"xiaohongshu",name:"note",access:"read",description:"获取小红书笔记正文和互动数据",domain:"www.xiaohongshu.com",strategy:h.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full Xiaohongshu note URL with xsec_token"}],columns:["field","value"],func:async(s,l)=>{const o=String(l["note-id"]),i=p(o),u=f(o,{commandName:"xiaohongshu note"}),t=await g(s,{url:u,extractJs:NOTE_EXTRACT_JS,securityHelp:/^https?:\/\//.test(o)?"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(!t||typeof t!=="object")throw new r("xiaohongshu/note","Unexpected evaluate response");if(t.loginWall)throw new m("www.xiaohongshu.com","Note content requires login");if(t.notFound)throw new r("xiaohongshu/note",`Note ${i} not found or unavailable — it may have been deleted or restricted`);const e=t,n=(c)=>/^\d+/.test(c)?c:"0";if(!e.title&&!e.author)throw new r("xiaohongshu/note","The note page loaded without visible content. The note may be deleted or restricted.");const a=[{field:"title",value:e.title||""},{field:"author",value:e.author||""},{field:"content",value:e.desc||""},{field:"likes",value:n(e.likes||"")},{field:"collects",value:n(e.collects||"")},{field:"comments",value:n(e.comments||"")}];if(e.tags?.length)a.push({field:"tags",value:e.tags.join(", ")});return a}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CliError as y}from"@jackwener/opencli/errors";export function jitterSeconds(t,e,o=Math.random){return t+o()*(e-t)}export function isSecurityBlock(t){return Boolean(t&&typeof t==="object"&&!Array.isArray(t)&&t.securityBlock)}export async function readXhsDetailPage(t,{url:e,extractJs:o,securityHelp:n,settleMinS:c=2,settleMaxS:s=5,retryOnBlock:u=!0,cooldownMinS:l=8,cooldownMaxS:w=18,rand:i=Math.random}={}){const a=async()=>{await t.goto(e);await t.wait({time:jitterSeconds(c,s,i)});return t.evaluate(o)};let r=await a();if(u&&isSecurityBlock(r)){await t.wait({time:jitterSeconds(l,w,i)});r=await a()}if(isSecurityBlock(r))throw new y("SECURITY_BLOCK","Xiaohongshu security block: the note detail page was blocked by risk control.",n);return r}export const __test__={jitterSeconds,isSecurityBlock,readXhsDetailPage};
|
package/dist/src/discovery.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import*as
|
|
2
|
-
`;try{if(await
|
|
1
|
+
import*as i from"node:fs";import*as R from"node:os";import*as c from"node:path";import{fileURLToPath as S,pathToFileURL as p}from"node:url";import A from"js-yaml";import{Strategy as y,registerCommand as E}from"./registry.js";import{getErrorMessage as m}from"./errors.js";import{log as u}from"./logger.js";import{findPackageRoot as k,getCliManifestPath as I}from"./package-paths.js";import{migrateLegacyUserRuntime as x}from"./user-runtime.js";x();export const USER_OPENCLI_DIR=c.join(R.homedir(),".ppcli"),USER_CLIS_DIR=c.join(USER_OPENCLI_DIR,"clis"),PLUGINS_DIR=c.join(USER_OPENCLI_DIR,"plugins");const M=/\b(?:cli|registerSiteAuthCommands|onStartup|onBeforeExecute|onAfterExecute)\s*\(/,h=/\.ya?ml$/i,g=new Set,F=new Map;function T(e,r=y.COOKIE){if(!e)return r;const t=e.toUpperCase();return y[t]??r}const D=k(S(import.meta.url));export async function ensureUserCliCompatShims(e=USER_OPENCLI_DIR){await i.promises.mkdir(e,{recursive:!0});const r=c.join(e,"package.json"),t=`${JSON.stringify({name:"opencli-user-runtime",private:!0,type:"module"},null,2)}
|
|
2
|
+
`;try{if(await i.promises.readFile(r,"utf-8")!==t)await i.promises.writeFile(r,t,"utf-8")}catch{await i.promises.writeFile(r,t,"utf-8")}const a=D,n=c.join(e,"node_modules","@jackwener"),s=c.join(n,"opencli");try{let o=!0;try{if(await i.promises.readlink(s)===a)o=!1}catch{}if(o){await i.promises.mkdir(n,{recursive:!0});try{await i.promises.rm(s,{recursive:!0,force:!0})}catch{}const l=process.platform==="win32"?"junction":"dir";await i.promises.symlink(a,s,l)}}catch(o){u.warn(`Could not create symlink at ${s}: ${m(o)}`)}}export async function ensureUserAdapters(){await i.promises.mkdir(USER_CLIS_DIR,{recursive:!0})}export async function discoverClis(...e){for(const r of e){const t=I(r);try{await i.promises.access(t);const a=await L(t,r);if(a){await j(r,a.manifestFiles);continue}}catch{}await W(r)}}async function L(e,r){try{const t=await i.promises.readFile(e,"utf-8"),a=JSON.parse(t),n=new Set;for(const s of a){if(!s.modulePath)continue;const o=c.resolve(r,s.modulePath);n.add(o);if(s.sourceFile)n.add(c.resolve(r,s.sourceFile));const l={site:s.site,name:s.name,aliases:s.aliases,description:s.description??"",access:s.access,example:s.example,domain:s.domain,strategy:T(s.strategy),browser:s.browser,args:s.args??[],columns:s.columns,defaultFormat:s.defaultFormat,pipeline:s.pipeline,source:s.sourceFile?c.resolve(r,s.sourceFile):o,navigateBefore:s.navigateBefore,siteSession:s.siteSession,defaultWindowMode:s.defaultWindowMode,_lazy:!0,_modulePath:o};E(l)}return{manifestFiles:n}}catch(t){u.warn(`Failed to load manifest ${e}: ${m(t)}`);return null}}async function W(e){try{await i.promises.access(e)}catch{return}const r=await i.promises.readdir(e,{withFileTypes:!0});await j(e);const t=r.filter((a)=>a.isDirectory()).map(async(a)=>{const n=a.name,s=c.join(e,n),o=await i.promises.readdir(s);await Promise.all(o.map(async(l)=>{const d=c.join(s,l);if(f(l))return;if(l.endsWith(".ts")&&!l.endsWith(".d.ts")&&!l.endsWith(".test.ts")){u.warn(`Ignoring TypeScript adapter ${d} — .ts adapters are no longer loaded. Rename to .js or convert to JavaScript.`);return}if(l.endsWith(".js")&&!l.endsWith(".d.js")&&!l.endsWith(".test.js")){if(!await w(d))return;await import(p(d).href).catch((C)=>{u.warn(`Failed to load module ${d}: ${m(C)}`)})}}))});await Promise.all(t)}export async function discoverPlugins(){try{await i.promises.access(PLUGINS_DIR)}catch{return}const e=(await i.promises.readdir(PLUGINS_DIR,{withFileTypes:!0})).sort((t,a)=>t.name.localeCompare(a.name)),r=[];for(const t of e){const a=c.join(PLUGINS_DIR,t.name);if(await Y(t,a))r.push({entry:t,pluginDir:a})}for(const{entry:t,pluginDir:a}of r)await P(a,{owner:`plugin ${t.name}`,pluginMode:!0});await Promise.all(r.map(async({entry:t,pluginDir:a})=>{await _(a,t.name)}))}async function _(e,r){let t;try{t=await i.promises.readdir(e)}catch(n){const s=n.code;if(s!=="ENOENT"&&s!=="ENOTDIR")u.warn(`Failed to scan plugin directory ${e}: ${m(n)}`);return}const a=new Set(t);await Promise.all(t.map(async(n)=>{const s=c.join(e,n);if(f(n))return;if(n.endsWith(".js")&&!n.endsWith(".d.js")){if(!await w(s))return;await import(p(s).href).catch((o)=>{u.warn(`Plugin ${r}/${n}: ${m(o)}`)})}else if(n.endsWith(".ts")&&!n.endsWith(".d.ts")&&!n.endsWith(".test.ts")){const o=n.replace(/\.ts$/,".js");if(a.has(o))return;u.warn(`Plugin ${r}/${n}: no compiled .js found. Run "ppcli plugin update ${r}" to re-transpile, or install esbuild.`)}}))}async function j(e,r){let t;try{t=await i.promises.readdir(e,{withFileTypes:!0})}catch{return}for(const a of t.filter((n)=>n.isDirectory()).sort((n,s)=>n.name.localeCompare(s.name))){const n=c.join(e,a.name);await P(n,{owner:`site ${a.name}`,manifestFiles:r})}}async function P(e,r){let t;try{t=await i.promises.readdir(e)}catch(s){u.warn(`Could not inspect YAML adapters for ${r.owner}: ${m(s)}`);return}const a=new Set(t),n=[];for(const s of t.filter(f).sort()){const o=c.join(e,s);if(!await N(o,r.pluginMode===!0))continue;if(await U(e,s,a,r.manifestFiles))continue;const l=await v(o);if(g.has(l))continue;g.add(l);n.push(s)}if(n.length===0)return;u.warn(`Ignoring ${n.length} YAML adapter${n.length===1?"":"s"} for ${r.owner}: ${n.join(", ")}. .yaml/.yml adapters are no longer loaded; convert them to .js with the cli() API.`)}function f(e){return h.test(e)}async function U(e,r,t,a){const n=r.replace(h,".js");if(!t.has(n))return!1;const s=c.resolve(e,n);if(a)return a.has(s);return b(s)}async function N(e,r){let t;try{t=await i.promises.readFile(e,"utf-8")}catch{return!r}let a;try{a=A.load(t)}catch{return!r}if(!a||typeof a!=="object"||Array.isArray(a))return!1;const n=a;if(typeof n.name!=="string"||!n.name.trim())return!1;return["site","description","access","strategy","browser","domain","args","columns","pipeline","url","request","steps","extract","output","func","method","headers"].some((o)=>(o in n))}async function v(e){try{return await i.promises.realpath(e)}catch{return c.resolve(e)}}async function O(e){const r=await v(e);try{const t=await i.promises.stat(e);return`${r}:${t.mtimeMs}:${t.size}`}catch{return r}}async function b(e){const r=await O(e);let t=F.get(r);if(!t){t=(async()=>{if(!await w(e))return!1;try{await import(p(e).href);return!0}catch(a){u.warn(`Failed to load module ${e}: ${m(a)}`);return!1}})();F.set(r,t)}return t}async function w(e){try{const r=await i.promises.readFile(e,"utf-8");return M.test(r)}catch(r){u.warn(`Failed to inspect module ${e}: ${m(r)}`);return!1}}async function Y(e,r){if(e.isDirectory())return!0;if(!e.isSymbolicLink())return!1;try{return(await i.promises.stat(r)).isDirectory()}catch(t){const a=t.code;if(a!=="ENOENT"&&a!=="ENOTDIR")u.warn(`Failed to inspect plugin link ${r}: ${m(t)}`);return!1}}
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type DoctorReport = {
|
|
|
29
29
|
issues: string[];
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
32
|
-
* Test connectivity
|
|
32
|
+
* Test connectivity with a daemon-to-extension command that does not resolve a
|
|
33
|
+
* page or create a Browser Bridge container window.
|
|
33
34
|
*/
|
|
34
35
|
export declare function checkConnectivity(opts?: {
|
|
35
36
|
timeout?: number;
|
package/dist/src/doctor.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import{DEFAULT_DAEMON_PORT as
|
|
2
|
-
This usually means the daemon crashed or exited right after serving the live probe.`);else if(!
|
|
3
|
-
This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.`);else if(
|
|
1
|
+
import{DEFAULT_DAEMON_PORT as w}from"./constants.js";import{sendCommand as g,setDaemonCommandTimeoutSeconds as h}from"./browser/daemon-client.js";import{getDaemonHealth as b}from"./browser/daemon-transport.js";import{getErrorMessage as y}from"./errors.js";import{getRuntimeLabel as k}from"./runtime-detect.js";import{aliasForContextId as C,loadProfileConfig as I}from"./browser/profile.js";import{formatDaemonVersion as f,isDaemonStale as V,staleDaemonIssue as S}from"./browser/daemon-version.js";import{findShadowedUserAdapters as D,formatAdapterShadowIssue as P}from"./adapter-shadow.js";const R=8,T="__doctor__";function p(e){const n=e.replace(/^v/,"").split("-")[0].split(".").map(Number);if(n.length<3||n.some(isNaN))return null;return[n[0],n[1],n[2]]}function O(e,n){const o=n.match(/^(>=?)\s*(\S+)$/);if(!o)return!0;const[,i,u]=o,r=p(e),c=p(u);if(!r||!c)return!0;const s=r[0]-c[0]||r[1]-c[1]||r[2]-c[2];return i===">="?s>=0:s>0}export async function checkConnectivity(e){const n=Date.now(),o=e?.timeout??R;h(o);try{await g("cookies",{domain:"opencli-probe.invalid",session:T,surface:"browser"});return{ok:!0,durationMs:Date.now()-n}}catch(i){return{ok:!1,error:y(i),durationMs:Date.now()-n}}finally{h(null)}}export async function runBrowserDoctor(e={}){const n=await checkConnectivity(),o=await b(),i=o.state!=="stopped",u=o.state==="ready",r=n.ok&&!i,c=n.ok&&i&&!u,s=V(o.status,e.cliVersion),l=o.status?.profiles,a=o.status?.extensionVersion,d=D(),t=[];if(r)t.push(`Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.
|
|
2
|
+
This usually means the daemon crashed or exited right after serving the live probe.`);else if(!i)t.push("Daemon is not running. It should start automatically when you run an ppcli browser command.");if(s&&e.cliVersion)t.push(S(o.status,e.cliVersion));if(c)t.push(`Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.
|
|
3
|
+
This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.`);else if(i&&!u)if(o.state==="profile-required")t.push(`Multiple Chrome profiles are connected to the daemon, but no default profile was selected.
|
|
4
4
|
Run ppcli profile list, then ppcli profile use <name>, or pass --profile <name>.`);else if(o.state==="profile-disconnected")t.push(`Selected browser profile is not connected: ${o.status?.contextId??"unknown"}.
|
|
5
5
|
Open that Chrome profile and make sure the ppcli extension is enabled.`);else t.push(`Daemon is running but the Chrome/Chromium extension is not connected.
|
|
6
6
|
If the extension is already installed, try: ppcli daemon restart
|
|
7
7
|
If the extension is not installed, install the PublishPort browser extension
|
|
8
|
-
from the PublishPort desktop app.`);if(a
|
|
8
|
+
from the PublishPort desktop app.`);if(u&&!a)t.push(`Extension is connected but did not report a version.
|
|
9
9
|
This usually means an outdated Browser Bridge extension.
|
|
10
|
-
Reload or reinstall the PublishPort browser extension via the PublishPort desktop app.`);if(!n.ok)t.push(`Browser connectivity test failed: ${n.error??"unknown"}`);const m=o.status?.extensionCompatRange;if(
|
|
11
|
-
Update via the PublishPort desktop app (or: npm install -g publishport-ppcli).`)}else if(
|
|
12
|
-
Update the PublishPort browser extension via the PublishPort desktop app.`)}if(d.length>0)t.push(P(d));return{cliVersion:e.cliVersion,daemonRunning:
|
|
10
|
+
Reload or reinstall the PublishPort browser extension via the PublishPort desktop app.`);if(!n.ok)t.push(`Browser connectivity test failed: ${n.error??"unknown"}`);const m=o.status?.extensionCompatRange;if(a&&e.cliVersion&&m){if(!O(e.cliVersion,m))t.push(`CLI version incompatible with extension: extension v${a} requires CLI ${m}, but CLI is v${e.cliVersion}
|
|
11
|
+
Update via the PublishPort desktop app (or: npm install -g publishport-ppcli).`)}else if(a&&e.cliVersion){const x=a.split(".")[0],v=e.cliVersion.split(".")[0];if(x!==v)t.push(`Extension major version mismatch: extension v${a} ≠ CLI v${e.cliVersion}
|
|
12
|
+
Update the PublishPort browser extension via the PublishPort desktop app.`)}if(d.length>0)t.push(P(d));return{cliVersion:e.cliVersion,daemonRunning:i,daemonFlaky:r,daemonStale:s,daemonVersion:o.status?.daemonVersion,extensionConnected:u,extensionFlaky:c,extensionVersion:a,connectivity:n,profiles:l,adapterShadows:d,issues:t}}export function renderBrowserDoctorReport(e){const n=[`ppcli v${e.cliVersion??"unknown"} doctor (${k()})`,""],o=e.daemonFlaky?"[WARN]":e.daemonStale?"[WARN]":e.daemonRunning?"[OK]":"[MISSING]",i=e.daemonFlaky?"unstable (running during live check, then stopped)":e.daemonRunning?`running on port ${w} (${e.daemonStale?`${f(e)}, stale; CLI v${e.cliVersion??"unknown"}`:f(e)})`:"not running";n.push(`${o} Daemon: ${i}`);const u=e.extensionFlaky||e.extensionConnected&&!e.extensionVersion?"[WARN]":e.extensionConnected?"[OK]":"[MISSING]",r=!e.extensionConnected?"":e.extensionVersion?` (v${e.extensionVersion})`:" (version unknown)",c=e.extensionFlaky?"unstable (connected during live check, then disconnected)":e.extensionConnected?"connected":"not connected";n.push(`${u} Extension: ${c}${r}`);if(e.profiles&&e.profiles.length>0){const s=I();n.push("","Profiles:");for(const l of e.profiles){const a=C(s,l.contextId),d=a?` (${a})`:"",t=s.defaultContextId===l.contextId?", default":"",m=l.extensionVersion?`v${l.extensionVersion}`:"version unknown";n.push(` • ${l.contextId}${d}: connected ${m}${t}`)}}if(e.connectivity){const s=e.connectivity.ok?"[OK]":"[FAIL]",l=e.connectivity.ok?`connected in ${(e.connectivity.durationMs/1000).toFixed(1)}s`:`failed (${e.connectivity.error??"unknown"})`;n.push(`${s} Connectivity: ${l}`)}if(e.issues.length){n.push("","Issues:");for(const s of e.issues)n.push(` • ${s}`)}else if(e.daemonRunning&&e.extensionConnected)n.push("","Everything looks good!");return n.join(`
|
|
13
13
|
`)}
|
package/dist/src/errors.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const EXIT_CODES={SUCCESS:0,GENERIC_ERROR:1,USAGE_ERROR:2,EMPTY_RESULT:66,SERVICE_UNAVAIL:69,TEMPFAIL:75,NOPERM:77,CONFIG_ERROR:78,INTERRUPTED:130};export class CliError extends Error{code;hint;exitCode;constructor(e,t,n,o=EXIT_CODES.GENERIC_ERROR){super(t);this.name=new.target.name;this.code=e;this.hint=n;this.exitCode=o}}const
|
|
2
|
-
`)}return String(e)}export function toEnvelope(e){const t=e instanceof Error&&e.cause?
|
|
1
|
+
export const EXIT_CODES={SUCCESS:0,GENERIC_ERROR:1,USAGE_ERROR:2,EMPTY_RESULT:66,SERVICE_UNAVAIL:69,TEMPFAIL:75,NOPERM:77,CONFIG_ERROR:78,INTERRUPTED:130};export class CliError extends Error{code;hint;exitCode;constructor(e,t,n,o=EXIT_CODES.GENERIC_ERROR){super(t);this.name=new.target.name;this.code=e;this.hint=n;this.exitCode=o}}const a=Symbol.for("ppcli.traceReceipt");export function attachTraceReceipt(e,t){if(!e||typeof e!=="object"&&typeof e!=="function")return;try{Object.defineProperty(e,a,{value:t,enumerable:!1,configurable:!0})}catch{}}export function getTraceReceipt(e){if(!e||typeof e!=="object"&&typeof e!=="function")return;return e[a]}export class BrowserConnectError extends CliError{kind;constructor(e,t,n="unknown"){super("BROWSER_CONNECT",e,t,EXIT_CODES.SERVICE_UNAVAIL);this.kind=n}}export class CommandExecutionError extends CliError{constructor(e,t){super("COMMAND_EXEC",e,t,EXIT_CODES.GENERIC_ERROR)}}export class ConfigError extends CliError{constructor(e,t){super("CONFIG",e,t,EXIT_CODES.CONFIG_ERROR)}}export class AuthRequiredError extends CliError{domain;constructor(e,t){super("AUTH_REQUIRED",t??`Not logged in to ${e}`,`Please open Chrome or Chromium and log in to https://${e}`,EXIT_CODES.NOPERM);this.domain=e}}export class TimeoutError extends CliError{constructor(e,t,n){super("TIMEOUT",`${e} timed out after ${t}s`,n??"Try again, or increase timeout with --timeout <seconds> (or OPENCLI_BROWSER_COMMAND_TIMEOUT for the global default)",EXIT_CODES.TEMPFAIL)}}export class RateLimitError extends CliError{constructor(e,t,n){const o=i(t);super("RATE_LIMITED",`Rate limit exceeded for ${e}`,`Retry after ${o}. To change this limit, edit ${n}.`,EXIT_CODES.TEMPFAIL)}}export class ArgumentError extends CliError{constructor(e,t){super("ARGUMENT",e,t,EXIT_CODES.USAGE_ERROR)}}export class SessionBusyError extends CliError{constructor(e,t){super("SESSION_BUSY",e,t,EXIT_CODES.TEMPFAIL)}}export class EmptyResultError extends CliError{constructor(e,t){super("EMPTY_RESULT",`${e} returned no data`,t??"This usually means the target genuinely has no matching content — "+"an empty result is a valid answer, not a failure, and can be relied on (e.g. no comments found = this account has not commented there). Only suspect a broken adapter or expired login if you have other evidence that content should be there.",EXIT_CODES.EMPTY_RESULT)}}export function adapterLoadError(e,t){return new CliError("ADAPTER_LOAD",e,t,EXIT_CODES.SERVICE_UNAVAIL)}export function selectorError(e,t){return new CliError("SELECTOR",`Could not find element: ${e}`,t??"The page UI may have changed. Please report this issue.",EXIT_CODES.GENERIC_ERROR)}export class PluginError extends CliError{constructor(e,t){super("PLUGIN",e,t,EXIT_CODES.GENERIC_ERROR)}}function i(e){const t=Math.max(0,Math.ceil(e));if(t<1000)return`${t}ms`;const n=Math.ceil(t/1000);if(n<60)return`${n}s`;const o=Math.ceil(n/60);if(o<60)return`${o}m`;return`${Math.ceil(o/60)}h`}export class LoginWallError extends CliError{status;url;bodyPreview;constructor(e,t,n,o,r){super("LOGIN_WALL",e,r??"The server returned an HTML page instead of JSON — likely a login wall, rate limit, or WAF challenge. Try re-logging in via your browser, or wait a few minutes before retrying.",EXIT_CODES.NOPERM);this.status=t;this.url=n;this.bodyPreview=o}}export function getErrorMessage(e){return e instanceof Error?e.message:String(e)}function c(e,t=0){if(t>10)return"(cause chain truncated)";if(e instanceof Error){const n=[e.message];if(e.cause)n.push(` caused by: ${c(e.cause,t+1)}`);return n.join(`
|
|
2
|
+
`)}return String(e)}export function toEnvelope(e){const t=e instanceof Error&&e.cause?c(e.cause):void 0,n=getTraceReceipt(e),o=n?{traceId:n.traceId,dir:n.traceDir,summaryPath:n.summaryPath,receiptPath:n.receiptPath,status:n.status}:void 0,r=e!==null&&typeof e==="object"&&typeof e.code==="string"&&typeof e.message==="string"&&typeof e.exitCode==="number";if(e instanceof CliError||r){const s=e;return{ok:!1,error:{code:s.code,message:s.message,...typeof s.hint==="string"&&s.hint?{help:s.hint}:{},exitCode:s.exitCode??EXIT_CODES.GENERIC_ERROR,...t?{cause:t}:{}},...o?{trace:o}:{}}}return{ok:!1,error:{code:"UNKNOWN",message:getErrorMessage(e),exitCode:EXIT_CODES.GENERIC_ERROR,...t?{cause:t}:{}},...o?{trace:o}:{}}}
|
package/dist/src/execution.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{getRegistry as oe,fullName as g}from"./registry.js";import{pathToFileURL as ie}from"node:url";import*as se from"node:crypto";import*as
|
|
2
|
-
`);try{
|
|
1
|
+
import{getRegistry as oe,fullName as g}from"./registry.js";import{pathToFileURL as ie}from"node:url";import*as se from"node:crypto";import*as M from"node:fs";import*as j from"node:os";import*as N from"node:path";import{executePipeline as V}from"./pipeline/index.js";import{adapterLoadError as ae,ArgumentError as d,CommandExecutionError as x,SessionBusyError as ce,attachTraceReceipt as ue,getErrorMessage as D}from"./errors.js";import{shouldUseBrowserSession as le}from"./capabilityRouting.js";import{getBrowserFactory as fe,browserSession as me,runWithTimeout as G,DEFAULT_BROWSER_COMMAND_TIMEOUT as de}from"./runtime.js";import{withBrowserSessionLockIf as pe}from"./browser-session-lock.js";import{resolveProfile as he}from"./browser/profile.js";import{ensureManagedChrome as we}from"./browser/managed-chrome.js";import{generateFingerprintJs as ge}from"./browser/fingerprint.js";import{CDPBridge as ye}from"./browser/index.js";import{clearDaemonRunContext as ve,generateRunId as Se,isUnknownOutcomeError as J,releaseSiteSessionLease as K,setDaemonCommandTimeoutSeconds as be,setDaemonRunContext as Q}from"./browser/daemon-client.js";import{emitHook as k}from"./hooks.js";import{setContentBaseDir as $e}from"./utils.js";import{log as X}from"./logger.js";import{isElectronApp as Ee}from"./electron-apps.js";import{probeCDP as Pe,resolveElectronEndpoint as xe}from"./launcher.js";import{ObservationSession as Ae,exportObservationSession as Ce}from"./observation/index.js";import{resolveAdapterSourcePath as Re}from"./adapter-source.js";const S=new Map,B=new Map,Te=`${j.homedir()}/.ppcli/clis/`;function Ie(e){if(e===void 0||e===null||e===""||e==="off")return"off";if(e==="on"||e==="retain-on-failure")return e;throw new d(`--trace must be one of: off, on, retain-on-failure. Received: "${String(e)}"`)}export function coerceAndValidateArgs(e,t){const n={...t};for(const r of e){const o=n[r.name];if(r.required&&(o===void 0||o===null||o===""))throw new d(`Argument "${r.name}" is required.`,r.help??`Provide a value for --${r.name}`);if(o!==void 0&&o!==null){if(r.type==="int"||r.type==="number"){const u=Number(o);if(!Number.isFinite(u))throw new d(`Argument "${r.name}" must be a valid number. Received: "${o}"`);if(r.type==="int"&&!Number.isInteger(u))throw new d(`Argument "${r.name}" must be a valid integer. Received: "${o}"`);n[r.name]=u}else if(r.type==="boolean"||r.type==="bool")if(typeof o==="string"){const u=o.toLowerCase();if(u==="true"||u==="1")n[r.name]=!0;else if(u==="false"||u==="0")n[r.name]=!1;else throw new d(`Argument "${r.name}" must be a boolean (true/false). Received: "${o}"`)}else n[r.name]=Boolean(o);const s=n[r.name];if(r.choices&&r.choices.length>0){if(!r.choices.map(String).includes(String(s)))throw new d(`Argument "${r.name}" must be one of: ${r.choices.join(", ")}. Received: "${s}"`)}}else if(r.default!==void 0)n[r.name]=r.default}return n}async function U(e,t,n,r){const o=e;if(o._lazy&&o._modulePath){const s=o._modulePath;if(s.startsWith(Te)&&S.has(s))try{const m=M.statSync(s),i=B.get(s);if(i!==void 0&&m.mtimeMs!==i)S.delete(s)}catch{}if(!S.has(s)){const m=ie(s).href,w=import(B.has(s)?`${m}?t=${Date.now()}`:m).then(()=>{try{B.set(s,M.statSync(s).mtimeMs)}catch{}},(p)=>{S.delete(s);throw ae(`Failed to load adapter module ${s}: ${D(p)}`,"Check that the adapter file exists and has no syntax errors.")});S.set(s,w)}await S.get(s);const l=oe().get(g(e));if(l?.func)return Y(l,t,n,r);if(l?.pipeline)return V(t,l.pipeline,{args:n,debug:r})}if(e.func)return Y(e,t,n,r);if(e.pipeline)return V(t,e.pipeline,{args:n,debug:r});throw new x(`Command ${g(e)} has no func or pipeline`,"This is likely a bug in the adapter definition. Please report this issue.")}function Y(e,t,n,r){if(e.browser===!1)return e.func(n,r);if(!t)throw new x(`Command ${g(e)} requires a browser session but none was provided`);return e.func(t,n,r)}function Me(e){if(e.navigateBefore===!1)return null;if(typeof e.navigateBefore==="string")return e.navigateBefore;return null}function Ne(e,t){if(!e||!t)return!1;try{const n=new URL(e).hostname;return n===t||n.endsWith(`.${t}`)}catch{return!1}}function De(e,t){if(!t)return!1;try{const n=new URL(e),r=n.hostname===t||n.hostname.endsWith(`.${t}`),o=n.pathname===""||n.pathname==="/";return r&&o&&n.search===""&&n.hash===""}catch{return!1}}async function ke(e,t,n,r){if(n!=="persistent"||!e.domain)return!0;if(!De(r,e.domain))return!0;const o=await t.getCurrentUrl?.().catch(()=>null);return!Ne(o,e.domain)}export async function executeCommand(e,t,n=!1,r={}){let o;try{o=r.prepared?t:prepareCommandArgs(e,t)}catch(i){if(i instanceof d)throw i;throw new d(D(i))}const s=qe(e,o);be(s);const u=Ie(r.trace),l={command:g(e),args:o,startedAt:Date.now()};await k("onBeforeExecute",l);let m;try{if(le(e)){const i=Ee(e.site),w=he(r.profile),p=!i&&w.kind==="managed"?w:null;let P;const A=Le(e.defaultWindowMode??"background",r.windowMode);let F,W;if(p){P=await we(p.name,p.entry,{windowMode:A});const c=p.entry.fingerprint;if(c?.enabled)F=ge(c);const a=p.entry.proxy;if(a?.username)W={username:a.username,password:a.password??""}}else if(i){const c=process.env.OPENCLI_CDP_ENDPOINT;if(c){const a=Number(new URL(c).port);if(!await Pe(a))throw new x(`CDP not reachable at ${c}`,"Check that the app is running with --remote-debugging-port and the endpoint is correct.");P=c}else P=await xe(e.site)}const re=p?ye:fe(e.site),L=!p&&w.kind==="extension"?w.contextId:void 0,ne=e,b=_e(e,r.siteSession),C=Oe(e,b),q=We(b,r.keepTab),y=b==="persistent"&&e.access==="write"?{runId:Se(),session:C}:null;if(y)Q({runId:y.runId,command:g(e),access:"write"});let z,H=!1,R=null;try{m=await pe(A!=="foreground",()=>me(re,async(c)=>{const a=u==="off"?null:new Ae({scope:{contextId:L??p?.name,session:C,target:c.getActivePage?.(),site:e.site,command:g(e),adapterSourcePath:Re(ne)}});if(a){a.record({stream:"action",name:"command",phase:"start",data:{args:o}});await c.startNetworkCapture?.().catch(()=>!1)}const h=Me(e);if(h&&await ke(e,c,b,h)){a?.record({stream:"action",name:"pre_navigate",phase:"start",data:{url:h}});try{await c.goto(h);a?.record({stream:"action",name:"pre_navigate",phase:"end",data:{url:h}})}catch(f){if(f instanceof ce)throw f;a?.record({stream:"action",name:"pre_navigate",phase:"error",data:{url:h,error:f instanceof Error?f.message:String(f)}});const v=new x(`Pre-navigation to ${h} failed: ${f instanceof Error?f.message:f}`,"Check that the site is reachable and the browser extension is running.");v.cause=f;if(a&&(u==="on"||u==="retain-on-failure")){a.record({stream:"error",message:v.message,stack:v.stack,code:v.code,hint:v.hint});await _(a,c).catch(()=>{});O(a,"failure",v,r.onTraceExport)}throw v}}const E=s!==null?s+Z:de,T=U(e,c,o,n);R=T;let I=!1;T.then(()=>{I=!0},()=>{I=!0});try{const f=await G(T,{timeout:E,label:g(e),hint:te(e,s)});a?.record({stream:"action",name:"command",phase:"end"});if(a&&u==="on"){await _(a,c).catch(()=>{});O(a,"success",void 0,r.onTraceExport)}if(!q)await c.closeWindow?.().catch(()=>{});return f}catch(f){if(!I)H=!0;if(a){a.record({stream:"action",name:"command",phase:"error",data:{error:f instanceof Error?f.message:String(f)}});a.record({stream:"error",message:f instanceof Error?f.message:String(f),stack:f instanceof Error?f.stack:void 0});if(u==="on"||u==="retain-on-failure"){await _(a,c).catch(()=>{});O(a,"failure",f,r.onTraceExport)}}if(!q)await c.closeWindow?.().catch(()=>{});throw f}},{session:C,cdpEndpoint:P,contextId:L,windowMode:A,surface:"adapter",siteSession:b,initScript:F,proxyAuth:W}),p?.name)}catch(c){z=c;throw c}finally{if(y)if(H&&R){const{runId:c,session:a}=y,h=(E)=>{ve(c);if(!J(E))K({runId:c,session:a,surface:"adapter"})};R.then(()=>h(),(E)=>h(E))}else{Q(null);if(!J(z))await K({runId:y.runId,session:y.session,surface:"adapter"})}}}else if(s!==null){const i=s+Z;m=await G(U(e,null,o,n),{timeout:i,label:g(e),hint:te(e,s)})}else m=await U(e,null,o,n)}catch(i){l.error=i;l.finishedAt=Date.now();await k("onAfterExecute",l);throw i}l.finishedAt=Date.now();await k("onAfterExecute",l,m);return m}async function _(e,t){const n=t.getActivePage?.()??e.scope.target,[r,o,s,u,l]=await Promise.all([t.getCurrentUrl?.().catch(()=>null)??Promise.resolve(null),t.snapshot().catch(()=>{return}),t.readNetworkCapture?.().catch(()=>[])??Promise.resolve([]),t.consoleMessages("all").catch(()=>[]),t.screenshot({format:"png"}).catch(()=>{return})]);if(o!==void 0||r!==void 0)e.record({stream:"state",url:r,target:n,snapshot:o,label:"final"});for(const m of Array.isArray(s)?s:[]){const i=m;e.record({stream:"network",url:String(i.url??""),method:typeof i.method==="string"?i.method:void 0,status:typeof i.responseStatus==="number"?i.responseStatus:void 0,contentType:typeof i.responseContentType==="string"?i.responseContentType:void 0,size:typeof i.responseBodyFullSize==="number"?i.responseBodyFullSize:void 0,requestHeaders:i.requestHeaders,responseHeaders:i.responseHeaders,requestBody:i.requestBodyPreview,responseBody:i.responsePreview,ts:typeof i.timestamp==="number"?i.timestamp:void 0})}for(const m of Array.isArray(u)?u:[])if(m&&typeof m==="object"){const i=m;e.record({stream:"console",level:String(i.type??i.level??"log"),text:String(i.text??i.message??""),ts:typeof i.timestamp==="number"?i.timestamp:void 0})}else e.record({stream:"console",level:"log",text:String(m)});if(typeof l==="string"&&l)e.record({stream:"screenshot",format:"png",data:l,label:"final"})}function O(e,t,n,r){try{const o=Ce(e,{error:n,status:t});if(t==="failure"&&n!==void 0)ue(n,o.receipt);else process.stderr.write(`ppcli trace artifact: ${o.dir}
|
|
2
|
+
`);try{r?.(o)}catch(s){X.warn(`[trace] Trace export callback failed: ${s instanceof Error?s.message:String(s)}`)}return o}catch(o){X.warn(`[trace] Failed to export trace artifact: ${o instanceof Error?o.message:String(o)}`);return}}export function resolveFileBackedArg(e,t){const n=e.args.find((l)=>l.fromFile);if(!n)return;const r=typeof n.fromFile==="string"?n.fromFile:"file",o=n.positional?`<${n.name}>`:`--${n.name}`,s=t[r]==null?"":String(t[r]).trim(),u=t[n.name]==null?"":String(t[n.name]);if(s){if(u.trim())throw new d(`${o} 和 --${r} 只能二选一,不能同时给`);const l=N.resolve(s.startsWith("~/")?N.join(j.homedir(),s.slice(2)):s);let m;try{m=M.readFileSync(l,"utf-8")}catch(w){throw new d(`--${r} 指向的文件读不到: ${l}(${D(w)})`)}const i=m.trim();if(!i)throw new d(`--${r} 指向的文件内容为空: ${l}`);t[n.name]=i;$e(N.dirname(l));return}if(!u.trim()&&n.requiredUnlessFile)throw new d(`缺少 ${n.name}:${o} 或 --${r} 给其一`)}export function prepareCommandArgs(e,t){const n=coerceAndValidateArgs(e.args,t);resolveFileBackedArg(e,n);e.validateArgs?.(n);return n}const Z=30;function Be(e){if(e===void 0||e===null||e==="")return null;if(e==="ephemeral"||e==="persistent")return e;throw new d(`--site-session must be one of: ephemeral, persistent. Received: "${String(e)}"`)}function Ue(){const e=process.env.PUBLISHPORT_SITE_SESSION;if(e==="persistent"||e==="ephemeral")return e;return null}function _e(e,t){return Be(t)??Ue()??e.siteSession??"persistent"}function Oe(e,t){if(t==="persistent")return`site:${e.site}`;return`site:${e.site}:${se.randomUUID()}`}function Fe(e,t){if(t===void 0||t==="")return null;if(t==="true")return!0;if(t==="false")return!1;throw new d(`${e} must be one of: true, false. Received: "${String(t)}"`)}function We(e,t){if(e==="persistent")return!0;return Fe("--keep-tab",t)??!1}function ee(e,t){if(t===void 0||t==="")return null;if(t==="foreground"||t==="background")return t;throw new d(`${e} must be one of: foreground, background. Received: "${String(t)}"`)}function Le(e="background",t){return ee("--window",t)??ee("OPENCLI_WINDOW",process.env.OPENCLI_WINDOW)??e}function te(e,t){const n=e.access==="write"?"⚠️ 这是写操作,超时不代表没写成功——请求可能已经到达平台。禁止直接重试:"+"先用该平台的列表/统计命令(stats / lists / articles 等)按标题回查,确认没写进去再重发。 ":"";if(t!==null)return`${n}Pass a higher --timeout value (currently ${t}s)`;if(e.args.some((r)=>r.name==="timeout"))return`${n}Try again, or pass a higher --timeout <seconds>`;return n+"This command has no --timeout option. Retry once; if it keeps timing out, "+"the page or browser bridge is likely stuck — have the user reopen the PublishPort client. "+"The global default can be raised with OPENCLI_BROWSER_COMMAND_TIMEOUT."}function qe(e,t){if(!e.args.some((o)=>o.name==="timeout"))return null;const n=t.timeout;if(n===void 0||n===null||n==="")throw new d(`Argument "timeout" must be a positive integer. Received: "${String(n)}"`);const r=Number(n);if(!Number.isInteger(r)||r<=0)throw new d(`Argument "timeout" must be a positive integer. Received: "${String(n)}"`);return r}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "publishport-opencli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.24",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": false
|
|
7
7
|
},
|
|
8
8
|
"description": "Make any website or Electron App your CLI. AI-powered.",
|
|
9
9
|
"engines": {
|
|
10
|
-
"node": ">=20.
|
|
10
|
+
"node": ">=20.18.1"
|
|
11
11
|
},
|
|
12
12
|
"type": "module",
|
|
13
13
|
"main": "dist/src/main.js",
|
|
@@ -87,13 +87,13 @@
|
|
|
87
87
|
"@mozilla/readability": "^0.6.0",
|
|
88
88
|
"cli-table3": "^0.6.5",
|
|
89
89
|
"commander": "^14.0.3",
|
|
90
|
-
"js-yaml": "^4.1
|
|
90
|
+
"js-yaml": "^4.3.1",
|
|
91
91
|
"markdown-draft-js": "^2.4.0",
|
|
92
92
|
"markdown-it": "^14.2.0",
|
|
93
93
|
"remarkable": "^2.0.1",
|
|
94
94
|
"turndown": "^7.2.2",
|
|
95
95
|
"turndown-plugin-gfm": "^1.0.2",
|
|
96
|
-
"undici": "^
|
|
96
|
+
"undici": "^7.29.0",
|
|
97
97
|
"ws": "^8.18.0"
|
|
98
98
|
},
|
|
99
99
|
"devDependencies": {
|