publishport-opencli 1.0.28 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-manifest.json +1297 -13
- package/clis/_shared/publish-controls.js +11 -0
- package/clis/douyin/_shared/ai-declaration.js +1 -0
- package/clis/douyin/_shared/browser-fetch.js +31 -13
- package/clis/douyin/_shared/comments.js +1 -0
- package/clis/douyin/_shared/publish-settings.js +1 -0
- package/clis/douyin/activities.js +1 -1
- package/clis/douyin/collections.js +1 -1
- package/clis/douyin/comment-like.js +1 -0
- package/clis/douyin/comment.js +1 -0
- package/clis/douyin/comments.js +1 -0
- package/clis/douyin/draft.js +8 -8
- package/clis/douyin/hashtag.js +1 -1
- package/clis/douyin/location.js +1 -1
- package/clis/douyin/music-categories.js +1 -0
- package/clis/douyin/music.js +1 -0
- package/clis/douyin/publish-image.js +12 -12
- package/clis/douyin/publish.js +9 -8
- package/clis/xiaohongshu/_shared/ai-declaration.js +1 -0
- package/clis/xiaohongshu/_shared/comment-actions.js +9 -0
- package/clis/xiaohongshu/_shared/comment-state.js +1 -0
- package/clis/xiaohongshu/_shared/publish-discovery.js +19 -0
- package/clis/xiaohongshu/_shared/publish-editors.js +1 -0
- package/clis/xiaohongshu/_shared/publish-settings.js +1 -0
- package/clis/xiaohongshu/_shared/topics.js +21 -0
- package/clis/xiaohongshu/activities.js +1 -0
- package/clis/xiaohongshu/collections.js +1 -0
- package/clis/xiaohongshu/comment-like.js +1 -0
- package/clis/xiaohongshu/comment.js +1 -0
- package/clis/xiaohongshu/comments.js +33 -5
- package/clis/xiaohongshu/groups.js +1 -0
- package/clis/xiaohongshu/location.js +1 -0
- package/clis/xiaohongshu/publish-video.js +10 -28
- package/clis/xiaohongshu/publish.js +43 -165
- package/clis/xiaohongshu/replies.js +1 -0
- package/clis/xiaohongshu/reply.js +1 -0
- package/clis/xiaohongshu/topics.js +1 -0
- package/dist/src/cli.js +27 -25
- package/package.json +1 -1
- package/skills/opencli-browser/SKILL.md +14 -3
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import{ArgumentError as o,CommandExecutionError as l}from"@jackwener/opencli/errors";export async function control(t,n,i){const e=await t.evaluate(`(({selector,label}) => {
|
|
2
|
+
const all = [...document.querySelectorAll(selector)];
|
|
3
|
+
const hits = all.filter(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden'
|
|
4
|
+
&& (label === undefined || (e.textContent || '').trim() === label));
|
|
5
|
+
if (hits.length !== 1) return {error: '期望唯一控件,找到 ' + hits.length};
|
|
6
|
+
const e = hits[0];
|
|
7
|
+
if (e.disabled || e.getAttribute('aria-disabled') === 'true'
|
|
8
|
+
|| [...e.classList].some(c => c === 'disabled' || c.startsWith('disabled-')))
|
|
9
|
+
return {error:'控件不可用'};
|
|
10
|
+
return {nth:all.indexOf(e)};
|
|
11
|
+
})(${JSON.stringify({selector:n,label:i})})`);if(!e||e.error)throw new l(`发布设置 ${i||n}:${e?.error||"页面响应异常"};已停止提交`);return{selector:n,nth:e.nth}}export async function clickControl(t,n,i){const e=await control(t,n,i);await t.evaluate(`document.querySelectorAll(${JSON.stringify(n)})[${e.nth}].scrollIntoView({block:'center',inline:'center',behavior:'instant'})`);await t.click(e.selector,{nth:e.nth});await t.wait({time:0.2})}export async function verifyControl(t,n,i,e=5000){for(let r=0;r<Math.ceil(e/200);r++){if(await t.evaluate(`Boolean(${n})`))return;await t.wait({time:0.2})}throw new l(`发布设置 ${i} 未生效;已停止提交`)}export async function fillControl(t,n,i,e=!0){const r=await control(t,n);await t.fillText(n,i,{nth:r.nth});if(e)await t.pressKey("Tab");await verifyControl(t,`document.querySelectorAll(${JSON.stringify(n)})[${r.nth}]?.value === ${JSON.stringify(i)}`,n)}export function publicationTime(t,n,i=Date.now()){if(t===void 0)return;const e=String(t);if(!/^\d+$/.test(e)&&!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/.test(e)&&!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i.test(e))throw new o("--schedule 使用北京时间 YYYY-MM-DD HH:mm、带时区 ISO8601 或 Unix 秒");const r=/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}$/.test(e)?e.replace(" ","T")+":00+08:00":e,a=/^\d+$/.test(e)?Number(e)*1000:Date.parse(r);if(!Number.isFinite(a)||a<i+n*3600000||a>i+1209600000)throw new o(`--schedule 必须在 ${n} 小时至 14 天后(无时区格式 YYYY-MM-DD HH:mm 按北京时间)`);return new Date(a+28800000).toISOString().slice(0,16).replace("T"," ")}export function resolveDeclaration(t,n){const i=t.declaration??(t["ai-generated"]?"ai":void 0);if(i!==void 0&&!Object.hasOwn(n,i))throw new o(`不支持的 --declaration: ${i}`);if(t["ai-generated"]&&i!=="ai")throw new o("--ai-generated 与 --declaration 冲突");return i}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CommandExecutionError as f}from"@jackwener/opencli/errors";export function douyinAiDeclarationInfo(t){return JSON.stringify(t?{choose_value:"aigc"}:{})}export function douyinAiDeclarationStep(t,s="内容由AI生成"){const n=(e)=>e.getClientRects().length>0&&getComputedStyle(e).visibility!=="hidden",i=(e)=>(e.textContent||"").replace(/\s+/g,""),o=[...document.querySelectorAll('[class*="selectBox-"]')].filter(n),r=[...document.querySelectorAll(".semi-modal")].filter((e)=>n(e)&&i(e).includes("对作品内容添加声明"));if(r.length===0&&o.filter((e)=>i(e)===s).length===1)return{selected:!0};if(t==="verify")return{selected:!1};if(t==="open"){if(r.length===1)return{opened:!0};const e=o.filter((d)=>i(d)==="请选择自主声明");if(e.length!==1)return{error:"未找到唯一的自主声明控件,或已有其他声明"};return{selector:'[class*="selectBox-"]',nth:[...document.querySelectorAll('[class*="selectBox-"]')].indexOf(e[0])}}if(r.length!==1)return{error:"未找到唯一的自主声明弹窗"};const c=[...r[0].querySelectorAll("label.semi-radio")].filter((e)=>n(e)&&i(e)===s);if(c.length!==1||!c[0].querySelector('input[type="radio"]'))return{error:"未找到唯一的“内容由AI生成”选项"};const l=c[0].querySelector('input[type="radio"]');if(l.disabled)return{error:"AI 声明选项不可用"};const a=c[0].classList.contains("semi-radio-checked");if(t==="select"){if(a)return{checked:!0};return{selector:'.semi-modal label.semi-radio input[type="radio"]',nth:[...document.querySelectorAll('.semi-modal label.semi-radio input[type="radio"]')].indexOf(l)}}if(!a)return{error:"AI 声明选项未选中"};const u=[...r[0].querySelectorAll("button")].filter((e)=>n(e)&&i(e)==="确定"&&!e.disabled&&e.getAttribute("aria-disabled")!=="true");if(u.length!==1)return{error:"声明确认按钮不可用"};return{selector:".semi-modal button",nth:[...document.querySelectorAll(".semi-modal button")].indexOf(u[0])}}export async function setDouyinAiDeclaration(t,s="内容由AI生成"){const n=(l)=>t.evaluate(`(${douyinAiDeclarationStep.toString()})(${JSON.stringify(l)}, ${JSON.stringify(s)})`),i=(l)=>{throw new f(`抖音 AI 内容声明设置失败:${l};已停止提交`)},o=await n("open");if(o?.selected)return;if(!o?.opened&&!o?.selector)i(o?.error||"声明控件响应异常");if(o.selector)await t.click(o.selector,{nth:o.nth});await t.wait({time:0.2});const r=await n("select");if(!r?.checked&&!r?.selector)i(r?.error||"声明选项响应异常");if(r.selector)await t.click(r.selector,{nth:r.nth});await t.wait({time:0.2});const c=await n("confirm");if(!c?.selector)i(c?.error||"声明确认响应异常");await t.click(c.selector,{nth:c.nth});for(let l=0;l<10;l++){if((await n("verify"))?.selected)return;await t.wait({time:0.2})}i("确认后未回显“"+s+"”")}
|
|
@@ -1,28 +1,46 @@
|
|
|
1
|
-
import{AuthRequiredError as
|
|
1
|
+
import{AuthRequiredError as l,CommandExecutionError as n}from"@jackwener/opencli/errors";import{unwrapEvaluateResult as u}from"./evaluate-result.js";function m(o,t){const r=String(t??"");return o===401||o===403||/login|cookie|auth|captcha|verify|forbidden|permission|登录|登陆|权限|验证|验证码/i.test(r)}export async function browserFetch(o,t,r,a={}){const c=`
|
|
2
2
|
(async () => {
|
|
3
3
|
const controller = new AbortController();
|
|
4
|
-
const timer = setTimeout(() => controller.abort(), ${Number(
|
|
4
|
+
const timer = setTimeout(() => controller.abort(), ${Number(a.timeoutMs??30000)});
|
|
5
|
+
let challengeTimer;
|
|
5
6
|
try {
|
|
6
|
-
|
|
7
|
+
${a.detectCaptcha?`
|
|
8
|
+
const captchaVisible = () => Array.from(document.querySelectorAll('iframe[src*="/verifycenter/captcha/"]')).some(frame => {
|
|
9
|
+
const rect = frame.getBoundingClientRect();
|
|
10
|
+
return rect.width > 0 && rect.height > 0 && getComputedStyle(frame).visibility !== 'hidden';
|
|
11
|
+
});
|
|
12
|
+
const challengeResult = { status_code: 403, status_msg: '抖音出现验证码;用 ppcli browser site:douyin captcha --adapter 截图,按图使用 captcha-click --adapter 点选并确认后再查询;不会自动重试。' };
|
|
13
|
+
if (captchaVisible()) return challengeResult;
|
|
14
|
+
const guard = new Promise(resolve => {
|
|
15
|
+
challengeTimer = setInterval(() => {
|
|
16
|
+
if (captchaVisible()) { resolve(challengeResult); controller.abort(); }
|
|
17
|
+
else if (controller.signal.aborted) resolve({ status_code: -1, status_msg: 'Douyin read request timed out' });
|
|
18
|
+
}, 200);
|
|
19
|
+
});`:""}
|
|
20
|
+
const request = (async () => {
|
|
21
|
+
const res = await fetch(${JSON.stringify(r)}, {
|
|
7
22
|
method: ${JSON.stringify(t)},
|
|
8
|
-
credentials:
|
|
23
|
+
credentials: ${JSON.stringify(a.credentials??"include")},
|
|
9
24
|
signal: controller.signal,
|
|
10
25
|
headers: {
|
|
11
26
|
'Content-Type': 'application/json',
|
|
12
|
-
...${JSON.stringify(
|
|
27
|
+
...${JSON.stringify(a.headers??{})}
|
|
13
28
|
},
|
|
14
|
-
${
|
|
29
|
+
${a.body?`body: JSON.stringify(${JSON.stringify(a.body)}),`:""}
|
|
15
30
|
});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { status_code: res.ok ? -2 : res.status, status_msg: \`JSON parse failed: \${text.slice(0, 500) || String(error && error.message || error)}\` };
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
return await ${a.detectCaptcha?"Promise.race([request, guard])":"request"};
|
|
22
39
|
} catch (error) {
|
|
23
40
|
return { status_code: -1, status_msg: String(error && error.message || error) };
|
|
24
41
|
} finally {
|
|
25
42
|
clearTimeout(timer);
|
|
43
|
+
clearInterval(challengeTimer);
|
|
26
44
|
}
|
|
27
45
|
})()
|
|
28
|
-
`;let
|
|
46
|
+
`;let e;try{e=u(await o.evaluate(c))}catch(s){throw new n(`Douyin API request failed (${t} ${r}): ${s instanceof Error?s.message:String(s)}`)}if(e==null)throw new n(`Empty response from Douyin API (${t} ${r})`);if(Array.isArray(e)||typeof e!=="object")throw new n(`Malformed response from Douyin API (${t} ${r})`);if(e&&typeof e==="object"&&"status_code"in e){const s=e.status_code;if(s!==0){const i=e.status_msg??e.message??"unknown error";if(m(s,i))throw new l(new URL(r).hostname,`Douyin API auth/permission error ${s} at ${t} ${r}: ${i}`);throw new n(`Douyin API error ${s} at ${t} ${r}: ${i}`)}}return e}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ArgumentError as w,AuthRequiredError as g,CommandExecutionError as l,EmptyResultError as p}from"@jackwener/opencli/errors";import{browserFetch as y}from"./browser-fetch.js";import{requireObjectEvaluateResult as h}from"./evaluate-result.js";export function commentId(n,r="评论 ID"){if(typeof n!=="string"||!/^[1-9]\d*$/.test(n))throw new w(`${r} 必须是十进制 ID 字符串,不能经过 Number 转换`);return n}export function videoId(n){if(typeof n==="string"&&/^[1-9]\d*$/.test(n))return n;try{const r=new URL(n),o=r.pathname.match(/^\/(?:video|note)\/([1-9]\d*)\/?$/);if(r.protocol==="https:"&&["www.douyin.com","douyin.com"].includes(r.hostname)&&o)return o[1]}catch{}throw new w("作品必须是抖音作品 ID 或 https://www.douyin.com/video/<ID> 链接")}export function commentCursor(n="0"){const r=String(n);if(!/^\d+$/.test(r)||!Number.isSafeInteger(Number(r)))throw new w("--cursor 必须是非负安全整数");return r}export function commentLimit(n=20){const r=Number(n);if(!Number.isInteger(r)||r<1||r>20)throw new w("--limit 必须在 1–20 之间");return r}export async function openComments(n,r){const o=`https://www.douyin.com/video/${r}`;if(await n.evaluate("location.origin + location.pathname")!==o)await n.goto(o)}export async function readCommentPage(n,r,{parent:o,cursor:u="0",limit:s=20,insertId:d}={}){const e=new URLSearchParams({aid:"6383",cursor:u,count:String(s)});if(o){e.set("item_id",r);e.set("comment_id",o);e.set("item_type","0");e.set("cut_version","1")}else{e.set("aweme_id",r);if(d)e.set("insert_ids",d)}const i=await y(n,"GET",`https://www.douyin.com/aweme/v1/web/comment/list/${o?"reply/":""}?${e}`,{detectCaptcha:!0});if(i.status_code!==0||!Array.isArray(i.comments)||![0,1,!1,!0].includes(i.has_more)||!Number.isSafeInteger(i.cursor)||i.cursor<0)throw new l("抖音评论接口返回了不完整的数据或分页信息");const c=i.comments.map((t)=>{if(typeof t.cid!=="string"||!/^[1-9]\d*$/.test(t.cid)||t.aweme_id!==r||typeof t.text!=="string"||typeof t.reply_id!=="string"||!/^\d+$/.test(t.reply_id)||(o?t.reply_id!==o:t.reply_id!=="0")||![0,1].includes(t.user_digged)||!Number.isSafeInteger(t.digg_count)||t.digg_count<0||typeof t.user?.uid!=="string"||typeof t.user?.nickname!=="string")throw new l("抖音评论字段或所属作品/楼层发生变化,已停止处理");const a=t.reply_to_reply_id??"0";if(typeof a!=="string"||!/^\d+$/.test(a))throw new l("抖音回复目标 ID 格式异常");return{cid:t.cid,aweme_id:r,text:t.text,nickname:t.user.nickname,uid:t.user.uid,digg_count:t.digg_count,liked:t.user_digged===1,reply_count:t.reply_comment_total??null,parent_id:t.reply_id==="0"?null:t.reply_id,reply_to_id:a==="0"?null:a,create_time:t.create_time??null,cursor:u,next_cursor:String(i.cursor),has_more:Boolean(i.has_more)}});if(i.has_more&&i.cursor<=Number(u))throw new l("抖音评论分页游标未前进,已停止翻页");return{rows:c,nextCursor:String(i.cursor),hasMore:Boolean(i.has_more)}}export const COMMENT_COLUMNS=["cid","aweme_id","text","nickname","uid","digg_count","liked","reply_count","parent_id","reply_to_id","create_time","cursor","next_cursor","has_more"];export async function readTargetComment(n,r,o,{parent:u,cursor:s="0"}={}){const e=(await readCommentPage(n,r,{parent:u,cursor:s,insertId:u?void 0:o})).rows.find((i)=>i.cid===o);if(!e)throw new p("douyin comment",`当前作品/评论页没有评论 ${o};楼中楼请传 --parent 和 replies 输出的 --cursor,不会替换为其他评论`);return e}export async function commentRuntime(n,r,o,u,s){const d=Date.now()+1e4;let e;while(Date.now()<d){if(Array.from(document.querySelectorAll('iframe[src*="/verifycenter/captcha/"]')).some((m)=>{const f=m.getBoundingClientRect();return f.width>0&&f.height>0&&getComputedStyle(m).visibility!=="hidden"}))return{error:"auth",message:"抖音出现验证码;用 ppcli browser site:douyin captcha --adapter 截图并点选确认,尚未提交"};const t=document.querySelector('[data-e2e="comment-list"]');let a=t?.[Object.keys(t).find((m)=>m.startsWith("__reactFiber"))];for(let m=0;a&&m<30;m++,a=a.return){const f=a.memoizedProps;if(f?.awemeInfo?.awemeId===n&&typeof f.puhlishCommentItem==="function"&&typeof f.diggCommentItem==="function"){e=f;break}}if(e)break;await new Promise((m)=>setTimeout(m,200))}if(!e)return{error:"runtime",message:"找不到当前作品的原生评论组件"};if(e.userInfo?.isLogin!==!0||!e.userInfo?.info?.uid)return{error:"auth",message:"抖音评论操作需要登录"};if(r==="publish"&&e.awemeInfo.awemeControl?.canComment!==!0)return{error:"runtime",message:"当前作品不允许评论或权限状态未知"};const i=String(e.userInfo.info.uid);if(s&&i!==s)return{error:"auth",message:"抖音账号在预检后已切换,已停止提交"};if(!u)return{ready:!0,uid:i};try{const c=r==="publish"?await e.puhlishCommentItem(o):await e.diggCommentItem(o),t=c?.comment;return{uid:i,status_code:c?.statusCode,status_msg:c?.statusMsg,comment:t?{cid:t.cid,text:t.text,reply_id:t.replyId,user_id:t.user?.uid}:null}}catch(c){return{error:"uncertain",message:String(c?.message??c)}}}export async function callCommentRuntime(n,r,o,u,s=!1,d){let e;try{e=h(await n.evaluate(`(${commentRuntime.toString()})(${JSON.stringify(r)},${JSON.stringify(o)},${JSON.stringify(u)},${s===!0},${JSON.stringify(d)})`),"抖音评论组件")}catch(i){throw new l(`抖音评论组件调用失败:${i.message}`,s?"提交结果未知;请先回查,不要直接重试。":"尚未提交。")}if(e.error==="auth")throw new g("www.douyin.com",e.message);if(e.error)throw new l(e.message,s?"可能已提交;请先回查,不要直接重试。":"尚未提交;页面组件需要重新适配。");if(s?e.status_code!==0:e.ready!==!0)throw new l(`抖音评论操作未成功确认:${e.status_code??"missing status"} ${e.status_msg??""}`,s?"请先回查,不要直接重试。":"尚未提交。");return e}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ArgumentError as s,CommandExecutionError as u}from"@jackwener/opencli/errors";import{clickControl as c,fillControl as m,verifyControl as n,publicationTime as h,resolveDeclaration as y}from"../../_shared/publish-controls.js";import{setDouyinAiDeclaration as S}from"./ai-declaration.js";export const DOUYIN_DECLARATIONS={ai:["aigc","内容由AI生成"],opinion:["personal_opinion","内容为个人观点或见解"],marketing:["marketing","内容含营销推广信息"],fiction:["only_fun_new","虚构演绎,仅供娱乐"],none:["__NO_NEED_USER_DECLARATION__","无需添加自主声明"],reprint:["repost","内容为转载信息"]},DOUYIN_DECLARATION_ARGS=[{name:"declaration",choices:Object.keys(DOUYIN_DECLARATIONS),help:"自主声明:ai / opinion / marketing / fiction / reprint / none"},{name:"source",choices:["external","douyin"],help:"转载来源:external站外 / douyin站内,reprint必填"},{name:"source-video",help:"站内转载来源作品ID(UI命令须在当前观看历史中)"}],DOUYIN_UI_ARGS=[...DOUYIN_DECLARATION_ARGS,{name:"schedule",help:"定时发布,北京时间 YYYY-MM-DD HH:mm / ISO8601 / Unix秒;2小时至14天后"},{name:"allow-download",type:"boolean",help:"允许保存作品(true/false,不传保留平台默认)"},{name:"collection",help:"合集完整名称;先用 douyin collections 查看账号已有合集"},{name:"location",help:"地点完整名称,按平台搜索结果精确选择"},{name:"location-address",help:"地点完整地址,搜索结果重名时用于区分(需 --location)"},{name:"hotspot",help:"关联热点完整名称"},{name:"activity",help:"官方活动完整名称;先用 activities 查询,再用 --id 读参与要求"},{name:"music",help:"音乐完整名称(需要同时指定 --music-author)"},{name:"music-author",help:"音乐作者完整名称,用于区分同名曲目;先用 douyin music 搜索"},{name:"music-id",help:"查询结果的精确音乐 ID,避免同名同作者误选"},{name:"music-query",help:"查询结果中的原搜索词,保留原候选范围"},{name:"music-category",help:"查询结果中的分类名称,与 music-query 二选一"}],DOUYIN_VIDEO_ARGS=[...DOUYIN_UI_ARGS,{name:"sync-toutiao",type:"boolean",help:"同步到今日头条;需公开且平台控件可用,不修改记住我的选择"}];export function validateDouyinSettings(e){if(e["location-address"]&&!e.location)throw new s("--location-address 需要 --location");if(e["sync-toutiao"]&&e.visibility&&e.visibility!=="public")throw new s("同步头条需要公开可见");if(["music-id","music-query","music-category"].some((o)=>e[o])&&!e.music)throw new s("音乐选择参数需要 --music 与 --music-author");if(e["music-query"]&&e["music-category"])throw new s("--music-query 与 --music-category 二选一");if(e["music-id"]&&!/^\d+$/.test(e["music-id"]))throw new s("--music-id 必须为精确音乐 ID");const t=y(e,DOUYIN_DECLARATIONS);f(e,t);if(!!e.music!==!!e["music-author"])throw new s("--music 与 --music-author 必须同时提供");if(e.visibility!==void 0&&!["public","friends","private"].includes(e.visibility))throw new s("不支持的 --visibility");return{declaration:t,schedule:h(e.schedule,2)}}export function douyinDeclarationInfo(e){const t=y(e,DOUYIN_DECLARATIONS);f(e,t);return JSON.stringify(t&&t!=="none"?{choose_value:DOUYIN_DECLARATIONS[t][0],...t==="reprint"?{source:e.source==="external"?"站外":"抖音作品",...e["source-video"]?{from_item_id:e["source-video"]}:{}}:{}}:{})}function f(e,t){if(t==="reprint"&&!["external","douyin"].includes(e.source))throw new s("转载声明需要 --source external 或 douyin");if(e.source==="douyin"&&!/^\d+$/.test(e["source-video"]||""))throw new s("站内转载需要 --source-video 作品ID");if(e["source-video"]&&e.source!=="douyin")throw new s("--source-video 仅用于 --source douyin");if((e.source||e["source-video"])&&t!=="reprint")throw new s("转载来源参数仅用于 --declaration reprint")}async function x(e,t){await c(e,'[class*="selectBox-"]',"请选择自主声明");await c(e,".semi-modal label.semi-radio","内容为转载信息");await c(e,".semi-modal label.semi-radio",t.source==="external"?"取材站外":"取材站内");if(t.source==="douyin"){const o=`((id)=>{const all=[...document.querySelectorAll('[class*="itemContainer-"]')];const found=all.filter(e=>{const f=e[Object.keys(e).find(k=>k.startsWith('__reactFiber'))];return f?.key===id;});return found.length===1 ? all.indexOf(found[0]) : -1;})(${JSON.stringify(t["source-video"])})`;await n(e,`(${o})>=0`,"观看历史中的来源作品");await e.click('[class*="itemContainer-"]',{nth:await e.evaluate(o)});const i=await e.evaluate("([...document.querySelectorAll('.semi-modal')].filter(e=>e.querySelector('.semi-modal-title')?.textContent.trim()==='观看历史').map(e=>[...document.querySelectorAll('.semi-modal button')].indexOf([...e.querySelectorAll('button')].find(b=>b.textContent.trim()==='确定'))))");if(i.length!==1||i[0]<0)throw new u("来源作品确认按钮不可用;已停止提交");await e.click(".semi-modal button",{nth:i[0]});await n(e,"![...document.querySelectorAll('.semi-modal-title')].some(e=>e.textContent.trim()==='观看历史')","来源作品选择")}await c(e,".semi-modal button","确定");await n(e,`![...document.querySelectorAll('.semi-modal')].some(e=>e.getClientRects().length) && [...document.querySelectorAll('[class*="selectBox-"]')].some(e=>e.textContent.trim()==='内容为转载信息')`,"转载声明")}async function d(e,t){if(await e.evaluate(`Boolean([...document.querySelectorAll('label[data-checked], label.semi-radio')].some(e=>e.textContent.trim()===${JSON.stringify(t)} && (e.dataset.checked==='true'||e.classList.contains('semi-radio-checked'))))`))return;await c(e,"label[data-checked], label.semi-radio",t);await n(e,`([...document.querySelectorAll('label[data-checked], label.semi-radio')].filter(e=>e.textContent.trim()===${JSON.stringify(t)}).some(e=>e.dataset.checked==='true'||e.classList.contains('semi-radio-checked')))`,t)}async function w(e,t,o,i,r){const l=Array.isArray(t)?t:[t],a=await e.evaluate(`([...document.querySelectorAll('.semi-select')].filter(e=>${JSON.stringify(l)}.includes(e.textContent.trim())).map(e=>e.textContent.trim()))`);if(a.length!==1)throw new u("找不到唯一的发布选项 "+l.join(" / ")+";已停止提交");await c(e,".semi-select",a[0]);if(r)await m(e,".semi-select-open input",o,!1);await n(e,`([...document.querySelectorAll(${JSON.stringify(i)})].some(e=>e.getClientRects().length && e.textContent.trim()===${JSON.stringify(o)}))`,"候选项 "+o);await c(e,i,o);await n(e,`([...document.querySelectorAll('.semi-select .semi-select-selection-text')].some(e=>e.textContent.trim()===${JSON.stringify(o)}))`,o)}export async function setDouyinPublishSettings(e,t){const o=validateDouyinSettings(t);if(o.declaration==="reprint")await x(e,t);else if(o.declaration)await S(e,DOUYIN_DECLARATIONS[o.declaration][1]);if(t.visibility)await d(e,{public:"公开",friends:"好友可见",private:"仅自己可见"}[t.visibility]);if(t["allow-download"]!==void 0)await d(e,t["allow-download"]?"允许":"不允许");if(o.schedule){await d(e,"定时发布");await m(e,'input[placeholder="日期和时间"]',o.schedule)}if(t.collection)await w(e,["不选择合集","请选择合集"],t.collection,".semi-select-option-text",!1);if(t.location){const i=await e.evaluate("([...document.querySelectorAll('.semi-select')].find(e=>['输入相关位置,让更多人看到你的作品','输入地理位置'].includes(e.textContent.trim()))?.textContent.trim())");if(!i)throw new u("找不到地点选择器;已停止提交");await c(e,".semi-select",i);await m(e,".semi-select-open input",t.location,!1);const r=`(({name,address})=>{const all=[...document.querySelectorAll('.semi-select-option')];const hits=all.filter(e=>e.getClientRects().length && e.querySelector('[class*="name-"]')?.textContent.trim()===name && (!address || e.querySelector('[class*="address-"]')?.textContent.trim()===address));return hits.length===1 ? all.indexOf(hits[0]) : -1;})(${JSON.stringify({name:t.location,address:t["location-address"]})})`;await n(e,`(${r})>=0`,"唯一地点(重名时指定 --location-address)");await e.click(".semi-select-option",{nth:await e.evaluate(r)});await n(e,`Boolean([...document.querySelectorAll('.semi-select-selection-text')].some(e=>e.textContent.trim()===${JSON.stringify(t.location)}))`,"地点")}if(t.hotspot)await w(e,"点击输入热点词",t.hotspot,".semi-select-option .select-word",!0);if(t.music){const i=await e.evaluate("location.pathname.includes('/post/video')");await c(e,i?'[class*="preview-button-"]':'[class*="action-"]',i?"添加音乐":"选择音乐");await n(e,`!!document.querySelector('input[placeholder="搜索音乐"]')?.getClientRects().length`,"音乐选择器",15000);if(t["music-category"])await c(e,'.semi-sidesheet [role="tab"]',t["music-category"]);else{await m(e,'input[placeholder="搜索音乐"]',t["music-query"]||t.music,!1);await e.pressKey("Enter")}const r=`(({title,author,id})=>{const musicId=e=>{let f=e[Object.keys(e).find(k=>k.startsWith('__reactFiber'))];for(let i=0;f&&i<5;i++,f=f.return)if(f.memoizedProps?.id&&f.memoizedProps?.title)return String(f.memoizedProps.id);};const cards=[...document.querySelectorAll('[class*="card-wrapper-"]')].filter(e=>e.getClientRects().length && e.querySelector('[class*="song-name-"]')?.textContent.trim()===title && e.querySelector('.song-author')?.textContent.trim()===author && (!id || musicId(e)===id));return cards.length===1 ? [...document.querySelectorAll('[class*="apply-btn-"]')].indexOf(cards[0].querySelector('[class*="apply-btn-"]')) : -1;})(${JSON.stringify({title:t.music,author:t["music-author"],id:t["music-id"]})})`;let l=-1;for(let a=0;a<40;a++){l=await e.evaluate(r);if(l>=0)break;await e.evaluate(`(() => { const list=document.querySelector('[class*="music-collection-container-"]'); if(list){list.scrollTop=list.scrollHeight;list.dispatchEvent(new Event('scroll',{bubbles:true}));} })()`);await e.wait({time:0.5})}if(l<0)throw new u("未在音乐选择器加载到唯一候选(20秒上限);请重新查询或缩小关键词,已停止提交");await e.click('[class*="apply-btn-"]',{nth:l});await n(e,i?`document.querySelector('[class*="music-text-"]')?.textContent.includes(${JSON.stringify(t.music+" - @"+t["music-author"])}) && !document.querySelector('.semi-sidesheet-close')`:`document.querySelector('[class*="sub-desc-title-"]')?.textContent.trim()===${JSON.stringify(t.music)}`,"音乐")}if(t.activity){const i='[class*="activity-list-container-"] [class*="title-"]';if(await e.evaluate(`Boolean([...document.querySelectorAll(${JSON.stringify(i)})].some(e=>e.textContent.trim()===${JSON.stringify(t.activity)}))`)){const l=`Boolean([...document.querySelectorAll(${JSON.stringify(i)})].find(e=>e.textContent.trim()===${JSON.stringify(t.activity)})?.closest('[class*="activity-right-"]')?.textContent.includes('已添加'))`;if(!await e.evaluate(l))await c(e,i,t.activity);await n(e,l,"活动")}else{const l=await e.evaluate(`([...document.querySelectorAll('[class*="more-"]')].filter(e=>e.getClientRects().length && /^\\+\\d+$/.test(e.textContent.trim())).map(e=>e.textContent.trim()))`);if(l.length!==1)throw new u("活动不在推荐中,且未找到完整活动列表;已停止提交");await c(e,'[class*="more-"]',l[0]);await n(e,`Boolean([...document.querySelectorAll('.semi-sidesheet [class*="title-"]')].some(e=>e.textContent.trim()===${JSON.stringify(t.activity)}))`,"活动 "+t.activity);const a=`Boolean([...document.querySelectorAll('.semi-sidesheet [class*="title-"]')].find(e=>e.textContent.trim()===${JSON.stringify(t.activity)})?.closest('[class*="container-checked-"]'))`;if(!await e.evaluate(a))await c(e,'.semi-sidesheet [class*="title-"]',t.activity);await n(e,a,"选中活动");await c(e,'.semi-sidesheet [class*="apply-"]',"确认");await n(e,`!document.querySelector('.semi-sidesheet-close') && document.querySelector('.zone-container')?.textContent.includes(${JSON.stringify(t.activity)})`,"活动话题")}}if(t["sync-toutiao"]!==void 0){await d(e,t["sync-toutiao"]?"同时发布到":"不同时发布");if(t["sync-toutiao"]){const i='[class*="app-group-container-item-"] .semi-switch',r=`document.querySelector(${JSON.stringify(i+" input")})?.checked === true`;await n(e,`document.querySelector('[class*="app-group-container-item-"]')?.textContent.trim()==='今日头条'`,"头条同步选项");if(!await e.evaluate(r))await c(e,i);await n(e,r,"同步头条")}}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as m,Strategy as s}from"@jackwener/opencli/registry";import{ArgumentError as a,CommandExecutionError as c}from"@jackwener/opencli/errors";import{browserFetch as l}from"./_shared/browser-fetch.js";m({site:"douyin",name:"activities",access:"read",domain:"creator.douyin.com",strategy:s.COOKIE,description:"查看/筛选官方活动;--id 查看参与规则。ui_args 用于图文/草稿,api_args 用于 publish;规则可能另有必带话题或@要求",args:[{name:"query",help:"按活动名称筛选"},{name:"id",help:"活动 ID,读取完整参与规则"},{name:"limit",type:"int",default:20}],columns:["activity_id","title","start_time","end_time","topics","requirements","ui_args","api_args"],func:async(d,t)=>{const e=t.limit??20;if(!Number.isInteger(e)||e<1||e>100)throw new a("--limit 必须为1至100的整数");if(t.id&&!/^\d+$/.test(t.id))throw new a("--id 必须为活动 ID");if(t.id&&t.query)throw new a("--id 与 --query 二选一");const n=await l(d,"GET",t.id?`https://creator.douyin.com/web/api/media/activity/detail/?activity_id=${t.id}`:"https://creator.douyin.com/web/api/media/activity/get/?page=1&size=9999&need_challenge=1&aid=1128"),o=t.id?n.activity_info?[n.activity_info]:null:n.activity_list;if(!Array.isArray(o))throw new c("活动响应缺少活动列表或详情,不能视为空结果");return o.filter((i)=>String(i.title??i.activity_name??"").toLowerCase().includes(String(t.query||"").toLowerCase())).slice(0,e).map((i)=>{const r=i.title??i.activity_name;if(!i.activity_id||!r)throw new c("活动缺少 ID 或名称");return{activity_id:String(i.activity_id),title:r,start_time:i.show_start_time||"",end_time:typeof i.end_time==="number"?new Date(i.end_time*1000).toLocaleString("zh-CN",{timeZone:"Asia/Shanghai"}):i.show_end_time||"",topics:i.challenge??null,requirements:t.id?n.activity_description??null:"用 activities --id "+i.activity_id+" 查看完整规则",ui_args:i.challenge?.length?{activity:r}:null,api_args:{activity:String(i.activity_id)}}})}});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as s,Strategy as a}from"@jackwener/opencli/registry";import{ArgumentError as e,CommandExecutionError as c}from"@jackwener/opencli/errors";import{browserFetch as u}from"./_shared/browser-fetch.js";s({site:"douyin",name:"collections",access:"read",description:"合集列表",domain:"creator.douyin.com",strategy:a.COOKIE,args:[{name:"limit",type:"int",default:20},{name:"cursor",default:"0",help:"上一页返回的 next_cursor"}],columns:["mix_id","name","item_count","next_cursor","has_more","ui_args","api_args"],func:async(n,o)=>{const i=o.limit??20;if(!Number.isInteger(i)||i<1||i>100)throw new e("--limit 必须为1至100的整数");if(!/^\d+$/.test(String(o.cursor??"0")))throw new e("--cursor 必须为非负整数游标");const m=`https://creator.douyin.com/web/api/mix/list/?status=0,1,2,3,6&count=${i}&cursor=${o.cursor??"0"}&should_query_new_mix=1&device_platform=web&aid=1128`,t=await u(n,"GET",m);if(!Array.isArray(t.mix_list))throw new c("合集响应缺少 mix_list,不能视为空列表");return t.mix_list.map((r)=>({mix_id:r.mix_id,name:r.mix_name,item_count:r.item_count,next_cursor:String(t.cursor??""),has_more:Boolean(t.has_more),ui_args:{collection:r.mix_name},api_args:{collection:String(r.mix_id)}}))}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cli as h,Strategy as _}from"@jackwener/opencli/registry";import{CommandExecutionError as s}from"@jackwener/opencli/errors";import{callCommentRuntime as p,commentCursor as C,commentId as w,openComments as I,readTargetComment as k,videoId as b}from"./_shared/comments.js";h({site:"douyin",name:"comment-like",access:"write",description:"点赞/取消点赞主评论或楼中楼;默认只读预检,--execute 才提交并回查",domain:"www.douyin.com",strategy:_.COOKIE,browser:!0,navigateBefore:!1,args:[{name:"aweme_id",required:!0,positional:!0,help:"作品 ID 或完整链接"},{name:"cid",required:!0,positional:!0,help:"目标评论 ID"},{name:"parent",type:"string",help:"目标为楼中楼时,传主评论 ID"},{name:"cursor",type:"string",default:"0",help:"楼中楼所在页的 cursor(来自 replies 输出)"},{name:"undo",type:"boolean",help:"取消点赞"},{name:"execute",type:"boolean",help:"实际提交一次,随后回查点赞状态"}],columns:["aweme_id","cid","parent_id","liked","status"],func:async(i,e)=>{const t=b(e.aweme_id),r=w(e.cid),o=e.parent==null?void 0:w(e.parent,"--parent"),u=C(e.cursor),n=e.undo!==!0;await I(i,t);const d=await k(i,t,r,{parent:o,cursor:u}),c={cid:r,diggType:n?1:2,level:o?3:1},f=await p(i,t,"like",c),a=(m,y)=>[{aweme_id:t,cid:r,parent_id:o??null,liked:m,status:y}];if(d.liked===n)return a(d.liked,"unchanged");if(e.execute!==!0)return a(d.liked,n?"would_like":"would_unlike");await p(i,t,"like",c,!0,f.uid);let l;try{l=await k(i,t,r,{parent:o,cursor:u})}catch(m){throw new s(`已提交点赞操作,但回查失败:${m.message}`,"先查看 comments/replies 的 liked,不要盲目重试。")}if(l.liked!==n)throw new s("已提交点赞操作,但回查状态不一致","先查看 comments/replies 的 liked,不要盲目重试。");return a(l.liked,n?"liked":"unliked")}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cli as b,Strategy as E}from"@jackwener/opencli/registry";import{ArgumentError as y,CommandExecutionError as h}from"@jackwener/opencli/errors";import{assertLiteralContent as T}from"../_shared/content-guard.js";import{callCommentRuntime as x,commentCursor as F,commentId as _,openComments as D,readCommentPage as g,readTargetComment as m,videoId as q}from"./_shared/comments.js";b({site:"douyin",name:"comment",access:"write",description:"发表评论或回复主评论/楼中楼;默认只读预检,--execute 才提交一次并回查",domain:"www.douyin.com",strategy:E.COOKIE,browser:!0,navigateBefore:!1,args:[{name:"aweme_id",required:!0,positional:!0,help:"作品 ID 或完整链接"},{name:"text",required:!0,positional:!0,fromFile:!0,help:"字面评论内容,最多 500 个 UTF-16 字符;支持 --file"},{name:"parent",type:"string",help:"回复主评论时传其 cid"},{name:"reply-to",type:"string",help:"回复楼中楼的 cid,必须同时传 --parent"},{name:"cursor",type:"string",default:"0",help:"被回复的楼中楼所在页 cursor(来自 replies 输出)"},{name:"execute",type:"boolean",help:"实际提交一次;默认仅验证账号、目标和原生提交入口"}],columns:["aweme_id","cid","text","parent_id","reply_to_id","status"],func:async(o,r)=>{const i=q(r.aweme_id),a=T(r.text,{label:"评论内容",fileFlag:"--file"});if(!a||a.length>500)throw new y("评论内容不能为空且不能超过 500 个 UTF-16 字符");const t=r.parent==null?void 0:_(r.parent,"--parent"),l=r["reply-to"]==null?void 0:_(r["reply-to"],"--reply-to");if(l&&!t)throw new y("--reply-to 必须同时指定 --parent 主评论 ID");const C=F(r.cursor);await D(o,i);if(t)await m(o,i,t);if(l)await m(o,i,l,{parent:t,cursor:C});const p={text:a,textExtra:[],...t?{replyId:t}:{},...l?{replyToReplyId:l}:{}},c=await x(o,i,"publish",p),s=(e,d)=>[{aweme_id:i,cid:e,text:a,parent_id:t??null,reply_to_id:l??null,status:d}];if(r.execute!==!0)return s(null,"prepared");const w=await x(o,i,"publish",p,!0,c.uid),n=w.comment;if(typeof n?.cid!=="string"||!/^[1-9]\d*$/.test(n.cid)||n.text!==a||n.reply_id!==(t??"0")||w.uid!==c.uid)throw new h("抖音提交已返回,但新评论 ID、内容或楼层无法确认","请先回查 comments/replies,不要直接重发。");try{let e;if(!t)e=await m(o,i,n.cid);else{let d="0";for(let f=0;f<5;f++){const u=await g(o,i,{parent:t,cursor:d});e=u.rows.find((I)=>I.cid===n.cid);if(e||!u.hasMore)break;d=u.nextCursor}}if(!e||e.text!==a||e.uid!==c.uid||e.parent_id!==(t??null)||e.reply_to_id!==(l??null))throw Error("未在回查范围(楼中楼前 5 页)读到匹配的新评论,或作者/内容/回复关系不一致")}catch(e){throw new h(`已提交评论 cid=${n.cid},但回查未通过:${e.message}`,"请按该 cid 核对,禁止直接重发。")}return s(n.cid,"posted")}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cli as c,Strategy as u}from"@jackwener/opencli/registry";import{EmptyResultError as a}from"@jackwener/opencli/errors";import{COMMENT_COLUMNS as d,commentCursor as l,commentId as p,commentLimit as f,openComments as w,readCommentPage as y,videoId as h}from"./_shared/comments.js";for(const e of[!1,!0])c({site:"douyin",name:e?"replies":"comments",access:"read",description:e?"查看主评论下的楼中楼,含回复目标 ID;使用 next_cursor 翻页":"查看作品评论、点赞状态及回复数;使用 next_cursor 翻页",domain:"www.douyin.com",strategy:u.COOKIE,browser:!0,navigateBefore:!1,args:[{name:"aweme_id",required:!0,positional:!0,help:"作品 ID 或抖音作品完整链接"},...e?[{name:"cid",required:!0,positional:!0,help:"主评论 ID(comments 输出的 cid)"}]:[],{name:"limit",type:"int",default:20,help:"每页数量,1–20"},{name:"cursor",type:"string",default:"0",help:"上一页 next_cursor,首次为 0"}],columns:d,func:async(r,t)=>{const o=h(t.aweme_id),n=e?p(t.cid):void 0,m=f(t.limit),s=l(t.cursor);await w(r,o);const i=await y(r,o,{parent:n,limit:m,cursor:s});if(!i.rows.length)throw new a(`douyin ${e?"replies":"comments"}`,"当前页没有评论;这不表示接口失败。");return i.rows}});
|
package/clis/douyin/draft.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import*as f from"node:fs";import*as s from"node:path";import{cli as
|
|
1
|
+
import{DOUYIN_VIDEO_ARGS as w,validateDouyinSettings as v,setDouyinPublishSettings as h}from"./_shared/publish-settings.js";import*as f from"node:fs";import*as s from"node:path";import{cli as T,Strategy as A}from"@jackwener/opencli/registry";import{ArgumentError as c,CommandExecutionError as l}from"@jackwener/opencli/errors";import{setInputFiles as m}from"../_shared/file-inject.js";const p={public:"公开",friends:"好友可见",private:"仅自己可见"},x="https://creator.douyin.com/creator-micro/content/upload",S=120,E=20,I=20,C=200;async function b(t){await t.evaluate(`() => {
|
|
2
2
|
const targets = ['我知道了', '知道了', '关闭'];
|
|
3
3
|
for (const text of targets) {
|
|
4
4
|
const btn = Array.from(document.querySelectorAll('button,[role="button"]'))
|
|
5
5
|
.find((el) => (el.textContent || '').trim() === text);
|
|
6
6
|
if (btn instanceof HTMLElement) btn.click();
|
|
7
7
|
}
|
|
8
|
-
}`)}async function
|
|
8
|
+
}`)}async function k(t){let n={href:"",ready:!1,bodyText:""};for(let e=0;e<S;e+=1){n=await t.evaluate(`() => ({
|
|
9
9
|
href: location.href,
|
|
10
10
|
ready: !!Array.from(document.querySelectorAll('input')).find(
|
|
11
11
|
(el) => (el.placeholder || '').includes('填写作品标题')
|
|
@@ -13,7 +13,7 @@ import*as f from"node:fs";import*as s from"node:path";import{cli as w,Strategy a
|
|
|
13
13
|
(el) => (el.textContent || '').includes('暂存离开')
|
|
14
14
|
),
|
|
15
15
|
bodyText: document.body?.innerText || ''
|
|
16
|
-
})`);if(n.ready)return;await t.wait({time:0.5})}throw new l("等待抖音草稿编辑页超时",`当前页面: ${n.href||"unknown"}`)}async function
|
|
16
|
+
})`);if(n.ready)return;await t.wait({time:0.5})}throw new l("等待抖音草稿编辑页超时",`当前页面: ${n.href||"unknown"}`)}async function _(t,n){for(let e=0;e<C;e+=1){const i=await t.evaluate(`() => {
|
|
17
17
|
const text = document.body?.innerText || '';
|
|
18
18
|
const uploading = /上传中|上传\\s*\\d+%|视频上传中|处理中/.test(text);
|
|
19
19
|
const saveBtn = Array.from(document.querySelectorAll('button')).find(
|
|
@@ -27,7 +27,7 @@ import*as f from"node:fs";import*as s from"node:path";import{cli as w,Strategy a
|
|
|
27
27
|
(el) => (el.textContent || '').includes(${JSON.stringify(n)})
|
|
28
28
|
);
|
|
29
29
|
return { uploading, saveEnabled, hasSaveBtn: !!saveBtn, hasCaptionEditor, hasVisibility };
|
|
30
|
-
}`);if(i.hasSaveBtn&&i.saveEnabled&&i.hasCaptionEditor&&i.hasVisibility&&!i.uploading)return;await t.wait({time:0.5})}}async function
|
|
30
|
+
}`);if(i.hasSaveBtn&&i.saveEnabled&&i.hasCaptionEditor&&i.hasVisibility&&!i.uploading)return;await t.wait({time:0.5})}}async function g(t,n){const e=60;let i=!1;for(let o=0;o<e;o+=1){i=await t.evaluate(`() => {
|
|
31
31
|
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
|
32
32
|
(el) => (el.placeholder || '').includes('填写作品标题')
|
|
33
33
|
);
|
|
@@ -71,7 +71,7 @@ import*as f from"node:fs";import*as s from"node:path";import{cli as w,Strategy a
|
|
|
71
71
|
if (!(visibility instanceof HTMLElement)) return false;
|
|
72
72
|
visibility.click();
|
|
73
73
|
return true;
|
|
74
|
-
}`);if(r)break;await t.wait({time:0.5})}if(!r)throw new l("填写抖音草稿表单失败: visibility-missing")}async function
|
|
74
|
+
}`);if(r)break;await t.wait({time:0.5})}if(!r)throw new l("填写抖音草稿表单失败: visibility-missing")}async function L(t){let n="cover-input-missing";const e=await t.evaluate(`() => Array.from(document.querySelectorAll('input[type="file"]')).length`);for(let i=0;i<E;i+=1){const r=await t.evaluate(`() => {
|
|
75
75
|
const coverLabel = Array.from(document.querySelectorAll('label')).find(
|
|
76
76
|
(el) => (el.textContent || '').includes('上传新封面')
|
|
77
77
|
);
|
|
@@ -110,7 +110,7 @@ import*as f from"node:fs";import*as s from"node:path";import{cli as w,Strategy a
|
|
|
110
110
|
root = root.parentElement;
|
|
111
111
|
}
|
|
112
112
|
return '';
|
|
113
|
-
}`}async function
|
|
113
|
+
}`}async function O(t){return await t.evaluate(buildCoverCheckPanelTextJs())||""}async function M(t){let n="",e=!1;for(let i=0;i<I;i+=1){const r=await O(t),o=r.includes("检测中"),a=r.includes("重新检测")||r.includes("横/竖双封面缺失");if(o)e=!0;if(e&&a&&!o)return;n=r;await t.wait({time:0.5})}throw new l("等待抖音封面处理完成超时",n||"unknown")}async function P(t){const n=60;let e=null;for(let i=0;i<n;i+=1){e=await t.evaluate(`() => {
|
|
114
114
|
const extractCreationId = () => {
|
|
115
115
|
const titleInput = Array.from(document.querySelectorAll('input')).find(
|
|
116
116
|
(el) => (el.placeholder || '').includes('填写作品标题')
|
|
@@ -160,7 +160,7 @@ import*as f from"node:fs";import*as s from"node:path";import{cli as w,Strategy a
|
|
|
160
160
|
text: (btn.textContent || '').trim(),
|
|
161
161
|
creationId,
|
|
162
162
|
};
|
|
163
|
-
}`);if(e?.ok)break;await t.wait({time:0.5})}if(!e?.ok)throw new l(`点击草稿按钮失败: ${e?.reason||"unknown"}`);if(!e.creationId)throw new l("点击草稿按钮失败: creation-id-missing");return{text:e.text||"暂存离开",creationId:e.creationId}}async function
|
|
163
|
+
}`);if(e?.ok)break;await t.wait({time:0.5})}if(!e?.ok)throw new l(`点击草稿按钮失败: ${e?.reason||"unknown"}`);if(!e.creationId)throw new l("点击草稿按钮失败: creation-id-missing");return{text:e.text||"暂存离开",creationId:e.creationId}}async function q(t,n){let e={href:"",bodyText:""};for(let i=0;i<40;i+=1){e=await t.evaluate(`() => ({
|
|
164
164
|
href: location.href,
|
|
165
165
|
bodyText: document.body?.innerText || ''
|
|
166
|
-
})`);const r=e.href||"",o=e.bodyText||"",a=r.includes("/creator-micro/content/upload")&&/继续编辑/.test(o),u=/草稿保存成功|已保存到草稿|存草稿成功|保存成功/.test(o),d=/\/creator-micro\/content\/(manage|drafts|works)/.test(r)||/创作中心/.test(o)&&!r.includes("/content/upload");if(a||u||d)return n;await t.wait({time:1})}throw new l("未检测到抖音草稿保存确认",`当前页面: ${e.href||"unknown"}`)}
|
|
166
|
+
})`);const r=e.href||"",o=e.bodyText||"",a=r.includes("/creator-micro/content/upload")&&/继续编辑/.test(o),u=/草稿保存成功|已保存到草稿|存草稿成功|保存成功/.test(o),d=/\/creator-micro\/content\/(manage|drafts|works)/.test(r)||/创作中心/.test(o)&&!r.includes("/content/upload");if(a||u||d)return n;await t.wait({time:1})}throw new l("未检测到抖音草稿保存确认",`当前页面: ${e.href||"unknown"}`)}T({site:"douyin",name:"draft",access:"write",description:"上传视频并保存为草稿",domain:"creator.douyin.com",strategy:A.COOKIE,navigateBefore:!1,defaultWindowMode:"foreground",args:[{name:"video",required:!0,positional:!0,help:"视频文件路径"},{name:"title",required:!0,help:"视频标题(≤30字)"},{name:"caption",fromFile:!0,default:"",help:"正文内容(≤1000字,支持 #话题)"},{name:"cover",default:"",help:"封面图片路径"},{name:"visibility",default:"public",choices:["public","friends","private"]},{name:"timeout",type:"int",default:180,help:"命令超时(秒),视频上传/转码慢时可调大"},{name:"ai-generated",type:"boolean",help:"声明内容由 AI 生成(发布和草稿均支持;设置失败时停止提交)"},...w],columns:["status","draft_id"],func:async(t,n)=>{v(n);const e=s.resolve(n.video);if(!f.existsSync(e))throw new c(`视频文件不存在: ${e}`);const i=s.extname(e).toLowerCase();if(![".mp4",".mov",".avi",".webm"].includes(i))throw new c(`不支持的视频格式: ${i}(支持 mp4/mov/avi/webm)`);const r=n.title;if(r.length>30)throw new c("标题不能超过 30 字");const o=n.caption||"";if(o.length>1000)throw new c("正文不能超过 1000 字");const a=n.cover;if(a){if(!f.existsSync(s.resolve(a)))throw new c(`封面文件不存在: ${s.resolve(a)}`)}const u=p[n.visibility]??p.public;await t.goto(x);await t.wait({selector:'input[type="file"]',timeout:20});await b(t);await m(t,'input[type="file"]',[e]);await k(t);await _(t,u);await b(t);if(a){const y=await L(t);await m(t,y,[s.resolve(a)]);await M(t)}await g(t,{title:r,caption:o,visibilityLabel:u});await t.wait({time:1});await h(t,{...n,visibility:void 0});const d=await P(t);return[{status:"✅ 草稿已保存,可在创作中心继续编辑",draft_id:await q(t,d.creationId)}]}});
|
package/clis/douyin/hashtag.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as w,Strategy as
|
|
1
|
+
import{cli as w,Strategy as p}from"@jackwener/opencli/registry";import{browserFetch as d}from"./_shared/browser-fetch.js";import{ArgumentError as l,CommandExecutionError as a}from"@jackwener/opencli/errors";function m(t){return t!==null&&typeof t==="object"&&!Array.isArray(t)}function y(t,n,i){if(!m(t))throw new a(`douyin hashtag ${i}: API returned malformed payload`);const r=t[n];if(r===void 0||r===null)throw new a(`douyin hashtag ${i}: API missing "${n}"`);if(!Array.isArray(r))throw new a(`douyin hashtag ${i}: API returned malformed "${n}"`);return r}function g(t){const n=t.action;if(!Number.isInteger(t.limit??10)||(t.limit??10)<1||(t.limit??10)>100)throw new l("--limit 必须为1至100的整数");if(n==="search"){if(!String(t.keyword??"").trim())throw new l("douyin hashtag search 需要 --keyword <关键词>","示例: ppcli douyin hashtag search --keyword 美食");return}if(n==="suggest"){if(!String(t.cover??"").trim())throw new l("douyin hashtag suggest 需要 --cover <cover_uri>","suggest 基于已上传的视频封面做 AI 推荐, 不是关键词搜索. 关键词搜索请用 `douyin hashtag search --keyword <词>`.")}}w({site:"douyin",name:"hashtag",access:"read",description:"查询发布话题 / 封面推荐 / 热点;publish_args 用于 publish-image,视频话题需放入 caption 中的 #话题",domain:"creator.douyin.com",strategy:p.COOKIE,args:[{name:"action",required:!0,positional:!0,choices:["search","suggest","hot"],help:"search=关键词搜索 (--keyword 必填), suggest=AI推荐 (--cover 必填), hot=热点词 (--keyword 可选)"},{name:"keyword",default:"",help:"搜索关键词. search 必填; hot 可选; suggest 不使用 (传 --cover)"},{name:"cover",default:"",help:"封面 URI (cover_uri). suggest 必填; 其它 action 不使用"},{name:"limit",type:"int",default:10}],columns:["name","id","view_count","publish_args"],validateArgs:g,func:async(t,n)=>{g(n);const i=n.action;if(i==="search"){const r=String(n.keyword??"").trim(),u=`https://creator.douyin.com/aweme/v1/search/challengesug/?keyword=${encodeURIComponent(r)}&source=challenge_create&aid=2906`,c=await d(t,"GET",u),s=y(c,"sug_list","search").slice(0,n.limit),o=s.flatMap((h)=>{const e=h;if(!m(e)||!e.cid||!e.cha_name)throw new a("话题候选缺少 ID 或名称");return[{name:e.cha_name,id:e.cid,view_count:e.view_count,publish_args:{topics:e.cha_name}}]});if(s.length>0&&o.length===0)throw new a("douyin hashtag search: API returned challenges but none had stable challenge_info shape");return o}if(i==="suggest"){const r=String(n.cover??"").trim(),u=`https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(r)}&aid=1128`,c=await d(t,"GET",u);return y(c,"challenge_list","suggest").slice(0,n.limit).map((o)=>({name:o.cha_name,id:o.cid,view_count:o.view_count??null,publish_args:{topics:o.cha_name}}))}if(i==="hot"){const r=String(n.keyword??"").trim(),u=`https://creator.douyin.com/aweme/v1/hotspot/recommend/?${r?`keyword=${encodeURIComponent(r)}&`:""}aid=1128`,c=await d(t,"GET",u);if(!m(c))throw new a("douyin hashtag hot: API returned malformed payload");const{hotspot_list:s,all_sentences:o}=c;if(s!==void 0&&s!==null&&!Array.isArray(s))throw new a('douyin hashtag hot: API returned malformed "hotspot_list"');if(o!==void 0&&o!==null&&!Array.isArray(o))throw new a('douyin hashtag hot: API returned malformed "all_sentences"');const h=Array.isArray(s)?s:Array.isArray(o)?o.map((e)=>({sentence:e?.word??"",hot_value:e?.hot_value,sentence_id:e?.sentence_id??""})):null;if(!h)throw new a("热点响应缺少候选数组,不能视为空结果");return h.slice(0,n.limit).map((e)=>({name:e?.sentence??"",id:e&&"sentence_id"in e?e.sentence_id:"",view_count:e?.hot_value??null,publish_args:{hotspot:e?.sentence}}))}throw new l(`未知的 action: ${i}`)}});
|
package/clis/douyin/location.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{ArgumentError as t,CommandExecutionError as n}from"@jackwener/opencli/errors";import{cli as m,Strategy as s}from"@jackwener/opencli/registry";import{browserFetch as d}from"./_shared/browser-fetch.js";m({site:"douyin",name:"location",access:"read",description:"搜索发布地点;ui_args 用于图文/视频草稿,api_args 用于 publish,重名地点按完整地址区分",domain:"creator.douyin.com",strategy:s.COOKIE,args:[{name:"query",required:!0,positional:!0,help:"地名关键词"},{name:"limit",type:"int",default:20},{name:"page",type:"int",default:1,help:"结果页码,从1开始"}],columns:["poi_id","name","address","city","has_more","next_page","ui_args","api_args"],func:async(a,e)=>{if(!String(e.query||"").trim()||!Number.isInteger(e.limit??20)||(e.limit??20)<1||(e.limit??20)>100||!Number.isInteger(e.page??1)||(e.page??1)<1)throw new t("请输入地点关键词、有效 limit(1至100) 和 page(>=1)");const r=`https://creator.douyin.com/aweme/v1/life/video_api/search/poi/?keywords=${encodeURIComponent(e.query)}&count=${e.limit??20}&page=${e.page??1}&from_webapp=1&aid=1128`,o=await d(a,"GET",r);if(!Array.isArray(o.poi_list))throw new n("地点响应缺少 poi_list,不能视为空结果");return o.poi_list.slice(0,e.limit??20).map((i)=>({poi_id:i.poi_id,name:i.poi_name,address:i.address_info?.simple_addr??i.address,city:i.address_info?.city??i.city_name,has_more:Boolean(o.has_more),next_page:o.has_more?o.page??(e.page??1)+1:null,ui_args:{location:i.poi_name,"location-address":i.address_info?.simple_addr??i.address},api_args:{poi_id:i.poi_id,poi_name:i.poi_name}}))}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cli as i,Strategy as t}from"@jackwener/opencli/registry";import{musicCategories as o}from"./music.js";i({site:"douyin",name:"music-categories",access:"read",domain:"creator.douyin.com",strategy:t.COOKIE,description:"查看推荐、收藏、榜单与音乐分类;将 name 传给 douyin music --category",args:[],columns:["id","name","type"],func:async(a)=>(await o(a)).map((e)=>({id:String(e.category_id),name:e.category_name,type:e.type}))});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cli as p,Strategy as d}from"@jackwener/opencli/registry";import{ArgumentError as s,CommandExecutionError as h}from"@jackwener/opencli/errors";import{browserFetch as c}from"./_shared/browser-fetch.js";export function requireMusicList(i,t){if(!Array.isArray(i?.[t]))throw new h(`配乐响应缺少 ${t},不能视为空列表`);return i[t]}export async function musicCategories(i){return requireMusicList(await c(i,"GET","https://creator.douyin.com/web/api/media/music/category"),"categories")}p({site:"douyin",name:"music",access:"read",domain:"creator.douyin.com",strategy:d.COOKIE,description:"搜索/查看发布配乐,返回试听地址、作者、时长及 draft/publish-image 可直接使用的 publish_args;视频仅标记音乐信息",args:[{name:"query",positional:!0,help:"音乐关键词;不传时查看 --category(默认推荐)"},{name:"category",help:"分类完整名称,先用 music-categories 查看;与 query 二选一"},{name:"cursor",default:"0",help:"上一页返回的 next_cursor;默认第一页"},{name:"limit",type:"int",default:20,help:"每页1至20首,后续用 cursor 翻页"}],columns:["id","title","author","duration","uses","preview_url","next_cursor","has_more","publish_args"],func:async(i,t)=>{const u=t.limit??20;if(!Number.isInteger(u)||u<1||u>20)throw new s("--limit 必须为1至20的整数");if(!/^\d+$/.test(String(t.cursor??"0")))throw new s("--cursor 必须为返回的非负整数游标");if(t.query&&t.category)throw new s("query 与 --category 二选一");let o,n,m;if(t.query){const r=await c(i,"GET","https://creator.douyin.com/web/api/media/aweme/search/post/auth");if(!r.signature)throw new h("平台未返回音乐搜索授权");const e=new URLSearchParams({aid:"1128",count:String(u),search_source:"normal_search",keyword:t.query,cursor:String(t.cursor??"0")});o=await c(i,"GET","https://tsearch.amemv.com/openapi/aweme/v1/music/search/?"+e,{credentials:"omit",headers:{"Agw-Auth":r.signature,"Openapi-Omit-Shark":"1"}});n=requireMusicList(o,"music");m={"music-query":t.query}}else{const r=t.category||"推荐",e=(await musicCategories(i)).filter((a)=>a.category_name===r);if(e.length!==1)throw new s("找不到唯一音乐分类,请先运行 douyin music-categories");const l=e[0],y=new URLSearchParams({category_id:l.category_id,type:l.type,count:String(u),cursor:String(t.cursor??"0")});o=await c(i,"GET","https://creator.douyin.com/web/api/media/music/list?"+y);n=requireMusicList(o,"songs").map((a)=>({...a,id_str:a.music_id,title:a.music_name,author:a.music_author}));m={"music-category":r}}return n.map((r)=>{const e=r.id_str??r.id;if(!e||typeof e==="number"&&!Number.isSafeInteger(e)||!r.title||!r.author)throw new h("配乐缺少精确 ID、标题或作者,停止返回不可用候选");return{id:String(e),title:r.title,author:r.author,duration:r.duration??null,uses:r.user_count??null,preview_url:typeof r.play_url==="string"?r.play_url:r.play_url?.url_list?.[0]||"",next_cursor:String(o.cursor??""),has_more:Boolean(o.has_more),publish_args:{music:r.title,"music-author":r.author,"music-id":String(e),...m}}})}});
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import*as b from"node:fs";import*as y from"node:path";import{cli as
|
|
1
|
+
import{DOUYIN_UI_ARGS as q,validateDouyinSettings as D,setDouyinPublishSettings as j}from"./_shared/publish-settings.js";import*as b from"node:fs";import*as y from"node:path";import{cli as B,Strategy as F}from"@jackwener/opencli/registry";import{ArgumentError as a,CommandExecutionError as u}from"@jackwener/opencli/errors";import{assertLiteralContent as k}from"../_shared/content-guard.js";import{setInputFiles as J}from"../_shared/file-inject.js";import{browserFetch as G}from"./_shared/browser-fetch.js";import{READ_IMAGE_DRAFT_JS as X,matchImageDraft as V}from"./_shared/draft-cache.js";const Y="https://creator.douyin.com/creator-micro/content/upload?default-tab=3",A=35,I=20,O=1000,z=2500,H={".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".bmp":"image/bmp",".webp":"image/webp",".tif":"image/tiff",".tiff":"image/tiff"},R='input[type="file"][accept*="image"]',C=['input[placeholder="添加作品标题"]','input[placeholder*="标题"]'],K='input[placeholder="添加作品标题"]',L='.zone-container[contenteditable="true"]';function W(t){return t.map((e)=>{const i=y.resolve(e);if(!b.existsSync(i))throw new a(`图片文件不存在: ${i}`);const r=y.extname(i).toLowerCase();if(!H[r])throw new a(`不支持的图片格式 "${r}",支持: jpg, png, bmp, webp, tif`);return i})}async function Q(t,e=15000){const i=500,r=Math.max(1,Math.ceil(e/i));for(let o=0;o<r;o++){if(await t.evaluate(`(() => !!document.querySelector(${JSON.stringify(R)}))()`))return!0;if(o<r-1)await t.wait({time:i/1000})}return!1}async function Z(t,e){if(!await Q(t))return{ok:!1,error:"上传页未渲染出图片输入框(等待 15s 超时)"};try{await J(t,R,e);return{ok:!0}}catch(r){return{ok:!1,error:r instanceof Error?r.message:String(r)}}}async function tt(t,e=20000){const i=500,r=Math.ceil(e/i);for(let o=0;o<r;o++){if(await t.evaluate(`(() => {
|
|
2
2
|
const onEditor = location.href.includes('/content/post/image');
|
|
3
|
-
const titleReady = ${JSON.stringify(
|
|
3
|
+
const titleReady = ${JSON.stringify(C)}.some((s) => !!document.querySelector(s));
|
|
4
4
|
return onEditor && titleReady;
|
|
5
|
-
})()`))return!0;await t.wait({time:
|
|
5
|
+
})()`))return!0;await t.wait({time:i/1000})}return!1}async function et(t,e){await t.typeText(K,e);await t.wait({time:0.3});const i=await t.evaluate(`
|
|
6
6
|
((selectors) => {
|
|
7
7
|
const el = selectors.map((s) => document.querySelector(s)).find(Boolean);
|
|
8
8
|
return el ? (el.value || '') : '';
|
|
9
|
-
})(${JSON.stringify(
|
|
10
|
-
`);if(
|
|
9
|
+
})(${JSON.stringify(C)})
|
|
10
|
+
`);if(i.trim()!==e){await t.screenshot({path:"/tmp/douyin_image_title_debug.png"});throw new u(`填写标题失败,期望 "${e}",实际 "${i}"。截图: /tmp/douyin_image_title_debug.png`)}}async function it(t,e){await t.typeText(L,e);await t.wait({time:0.5});const i=await t.evaluate(`
|
|
11
11
|
((selector) => {
|
|
12
12
|
const el = Array.from(document.querySelectorAll(selector)).find((n) => n && n.offsetParent !== null);
|
|
13
13
|
return el ? (el.innerText || el.textContent || '').replace(/\\u200b/g, '').trim() : '';
|
|
14
|
-
})(${JSON.stringify(
|
|
15
|
-
`),r=
|
|
14
|
+
})(${JSON.stringify(L)})
|
|
15
|
+
`),r=e.split("#")[0].replace(/\s+/g,"").trim(),o=i.replace(/\s+/g,"");if(r&&!o.includes(r.slice(0,Math.min(r.length,20)))){await t.screenshot({path:"/tmp/douyin_image_body_debug.png"});throw new u(`正文写入校验失败,实际读到 "${i.slice(0,80)}"。截图: /tmp/douyin_image_body_debug.png`)}}async function nt(t,e){const i=e?["暂存离开"]:["发布"];return await t.evaluate(`
|
|
16
16
|
((labels) => {
|
|
17
17
|
const isVisible = (el) => {
|
|
18
18
|
if (!el || el.offsetParent === null) return false;
|
|
@@ -27,8 +27,8 @@ import*as b from"node:fs";import*as y from"node:path";import{cli as k,Strategy a
|
|
|
27
27
|
if (btn) { btn.click(); return { ok: true, text: label }; }
|
|
28
28
|
}
|
|
29
29
|
return { ok: false, seen: btns.filter(isVisible).map((b) => norm(b.innerText || b.textContent)).filter(Boolean).slice(0, 12) };
|
|
30
|
-
})(${JSON.stringify(
|
|
31
|
-
`)}
|
|
30
|
+
})(${JSON.stringify(i)})
|
|
31
|
+
`)}B({site:"douyin",name:"publish-image",access:"write",description:"发布图文(图集)到抖音(UI 自动化,最多 35 张图;话题以 #词 写进正文)",domain:"creator.douyin.com",strategy:F.COOKIE,browser:!0,navigateBefore:!1,defaultWindowMode:"foreground",args:[{name:"content",required:!1,positional:!0,help:"正文(字面文本,不展开 @文件 引用;长正文用 --file)"},{name:"file",required:!1,help:"从本机文件读取正文(UTF-8),与位置参数 <content> 二选一"},{name:"title",required:!0,help:"作品标题(≤20字)"},{name:"images",required:!0,help:"图片路径,逗号分隔,最多35张 (jpg/png/bmp/webp/tif)"},{name:"topics",required:!1,help:"话题标签,逗号分隔,不含 # 号"},{name:"draft",type:"bool",default:!1,help:"暂存为草稿,不直接发布"},{name:"ai-generated",type:"boolean",help:"声明内容由 AI 生成(发布和草稿均支持;设置失败时停止提交)"},...q,{name:"visibility",choices:["public","friends","private"],help:"可见范围(不传保留平台默认)"}],columns:["status","detail"],func:async(t,e)=>{D(e);if(!t)throw Error("Browser page required");const i=String(e.title??"").trim(),r=e.file?String(e.file).trim():"";let o=String(e.content??"").trim();if(r){if(o)throw new a("正文位置参数和 --file 只能二选一");const n=y.resolve(r);if(!b.existsSync(n))throw new a(`--file 指向的文件不存在: ${n}`);o=b.readFileSync(n,"utf-8").trim();if(!o)throw new a(`--file 指向的文件内容为空: ${n}`)}const m=String(e.images??"").split(",").map((n)=>n.trim()).filter(Boolean),p=e.topics?String(e.topics).split(",").map((n)=>n.trim().replace(/^#/,"")).filter(Boolean):[],c=Boolean(e.draft);if(!i)throw new a("--title 必填");if(i.length>I)throw new a(`标题 ${i.length} 字,需 ≤ ${I} 字`);if(!o)throw new a("缺少正文:给位置参数 <content>,或用 --file <本机文件路径>");k(o,{fileFlag:"--file"});if(o.length>O)throw new a(`正文 ${o.length} 字,需 ≤ ${O} 字`);if(m.length===0)throw new a("至少提供 1 张图片(--images)");if(m.length>A)throw new a(`图片过多: ${m.length}(最多 ${A} 张)`);const _=W(m),N=p.length?`${o} ${p.map((n)=>`#${n}`).join(" ")} `:o;await t.goto(Y);let d="";for(let n=0;n<30;n++){await t.wait({time:0.5});d=await t.evaluate("() => location.href");if(d.includes("creator.douyin.com/creator-micro/content"))break}if(!d.includes("creator.douyin.com")){await t.screenshot({path:"/tmp/douyin_image_redirect_debug.png"});throw new u(`被重定向离开创作者中心(当前 ${d})——登录态可能已过期。`+"用 ppcli douyin login 重新登录。截图: /tmp/douyin_image_redirect_debug.png")}const S=await Z(t,_);if(!S.ok){await t.screenshot({path:"/tmp/douyin_image_upload_debug.png"});throw new u(`图片上传失败: ${S.error??"unknown"}。截图: /tmp/douyin_image_upload_debug.png`)}if(!await tt(t)){await t.screenshot({path:"/tmp/douyin_image_form_debug.png"});throw new u("上传后编辑页未就绪(标题输入框未出现),页面结构可能变化。截图: /tmp/douyin_image_form_debug.png")}await t.wait({time:z/1000});await et(t,i);await t.wait({time:0.4});await it(t,N);await t.wait({time:0.6});await j(t,e);const E=await nt(t,c);if(!E?.ok){await t.screenshot({path:"/tmp/douyin_image_submit_debug.png"});throw new u(`找不到「${c?"暂存离开":"发布"}」按钮。可见按钮: ${(E?.seen||[]).join(" | ")||"无"}。截图: /tmp/douyin_image_submit_debug.png`)}const M=c?["暂存成功","保存成功","存入草稿"]:["发布成功","提交成功"];let l=d,w=!1,g="";for(let n=0;n<24;n++){await t.wait({time:0.5});const s=await t.evaluate(`
|
|
32
32
|
((markers) => {
|
|
33
33
|
const url = location.href;
|
|
34
34
|
let toast = '';
|
|
@@ -39,7 +39,7 @@ import*as b from"node:fs";import*as y from"node:path";import{cli as k,Strategy a
|
|
|
39
39
|
if (text && text.length <= 40 && markers.some((m) => text.includes(m))) { toast = text; break; }
|
|
40
40
|
}
|
|
41
41
|
return { url, toast };
|
|
42
|
-
})(${JSON.stringify(
|
|
43
|
-
`);l=s?.url??l;if(s?.toast){g=s.toast;break}if(l.includes("/content/manage")||c&&l.includes("/content/draft")){w=!0;break}}let f=null;if(!w&&!g&&l.includes("creator.douyin.com"))try{if(c){const
|
|
42
|
+
})(${JSON.stringify(M)})
|
|
43
|
+
`);l=s?.url??l;if(s?.toast){g=s.toast;break}if(l.includes("/content/manage")||c&&l.includes("/content/draft")){w=!0;break}}let f=null;if(!w&&!g&&l.includes("creator.douyin.com"))try{if(c){const n=await t.evaluate(X),s=V(n,{title:i,content:o});f=s.hit?s:null}else{const s=await G(t,"GET","https://creator.douyin.com/janus/douyin/creator/pc/work_list?page_size=20&page_num=1&status=0"),U=s.aweme_list??s.data?.work_list??[],T=o.replace(/\s+/g,"").slice(0,20),P=Date.now()/1000;f=U.find((h)=>{const v=String(h.desc??h.title??"").replace(/\s+/g,""),x=Number(h.create_time??0);return x>0&&P-x<900&&(v.includes(i.replace(/\s+/g,""))||T&&v.includes(T))})??null}}catch{}if(!w&&!g&&!f){await t.screenshot({path:"/tmp/douyin_image_verify_debug.png"});throw new u(c?"暂存结果无法确认:未见成功提示,本机草稿缓存里也没找到本次内容——大概率没存下,重试是安全的。"+`当前 URL: ${l}。截图: /tmp/douyin_image_verify_debug.png`:"发布结果无法确认:未跳转作品管理页、未见成功提示,作品列表回查也没找到本次内容。"+`
|
|
44
44
|
⚠️ 禁止直接重试——发布请求可能已经打到抖音,重试会发出重复作品。`+"\n正确做法:先用 `ppcli douyin videos` 按标题回查最近作品,确认没有再重发。"+`
|
|
45
|
-
当前 URL: ${l}。截图: /tmp/douyin_image_verify_debug.png`)}return[{status:`✅ ${c?"暂存成功":"发布成功"}`,detail:[`"${
|
|
45
|
+
当前 URL: ${l}。截图: /tmp/douyin_image_verify_debug.png`)}return[{status:`✅ ${c?"暂存成功":"发布成功"}`,detail:[`"${i}"`,`${_.length}张图片`,p.length?`话题: ${p.map((n)=>`#${n}`).join(" ")}`:"",f?f.aweme_id?`aweme_id: ${f.aweme_id}(作品列表回查坐实)`:"本机草稿缓存回查坐实":"",w?l:g].filter(Boolean).join(" · ")}]}});
|
package/clis/douyin/publish.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import*as T from"node:fs";import*as
|
|
1
|
+
import{DOUYIN_DECLARATION_ARGS as k,douyinDeclarationInfo as ee}from"./_shared/publish-settings.js";import*as T from"node:fs";import*as v from"node:path";import{cli as te,Strategy as oe}from"@jackwener/opencli/registry";import{ArgumentError as _,CommandExecutionError as u}from"@jackwener/opencli/errors";import{getUploadAuthV5Credentials as ie,applyVideoUploadInner as se,commitVideoUploadInner as ne}from"./_shared/vod-upload.js";import{tosUpload as re}from"./_shared/tos-upload.js";import{imagexUpload as ae}from"./_shared/imagex-upload.js";import{browserFetch as V}from"./_shared/browser-fetch.js";import{requireObjectEvaluateResult as M}from"./_shared/evaluate-result.js";import{generateCreationId as ce}from"./_shared/creation-id.js";import{validateTiming as le,toUnixSeconds as de}from"./_shared/timing.js";import{parseTextExtra as me,extractHashtagNames as pe}from"./_shared/text-extra.js";const _e={public:0,friends:2,private:1},z="https://imagex.bytedanceapi.com",L="1147",ue="aid=1128&cookie_enabled=true&screen_width=1512&screen_height=982&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FTokyo&support_h265=1",fe=JSON.stringify({video_cover_source:2,cover_timestamp:0,recommend_timestamp:0,is_cover_edit:0,is_cover_template:0,cover_template_id:"",is_text_template:0,text_template_id:"",text_template_content:"",is_text:0,text_num:0,text_content:"",is_use_sticker:0,sticker_id:"",is_use_filter:0,filter_id:"",is_cover_modify:0,to_status:0,cover_type:0,initial_cover_uri:"",cut_coordinate:""});function he(o){const e=o instanceof Error?o.message:String(o);return e.includes("post_assistant/fast_detect")&&(e.includes("Empty response")||e.includes("404")||e.includes("Not Found")||e.includes("Timeout")||e.includes("timed out")||e.includes("Failed to fetch"))}function C(o){return new Promise((e)=>setTimeout(e,o))}function j(o,e){const m=e?.ResponseMetadata?.Error??e?.Error;if(m)throw new u(`${o}失败: ${JSON.stringify(m)}`)}async function B(o,e,m,r){let f;for(let p=1;p<=3;p+=1)try{return{ok:!0,value:await V(o,e,m,r)}}catch(s){if(!he(s))throw s;f=s;if(p<3)await C(500*p)}return{ok:!1,error:f}}te({site:"douyin",name:"publish",access:"write",description:"发布视频到抖音(指定 --schedule 定时,或显式 --immediate 立即发布)",domain:"creator.douyin.com",strategy:oe.COOKIE,defaultWindowMode:"foreground",args:[{name:"video",required:!0,positional:!0,help:"视频文件路径"},{name:"title",required:!0,help:"视频标题(≤30字)"},{name:"schedule",help:"定时发布时间(ISO8601 或 Unix 秒,2h ~ 14天后),与 --immediate 二选一"},{name:"immediate",type:"boolean",help:"立即发布(必须显式指定,不能与 --schedule 同用)"},{name:"caption",fromFile:!0,default:"",help:"正文内容(≤1000字,支持 #话题)"},{name:"cover",default:"",help:"封面图片路径(不提供时使用视频截帧)"},{name:"visibility",default:"public",choices:["public","friends","private"]},{name:"allow_download",type:"bool",default:!1,help:"允许下载"},{name:"collection",default:"",help:"合集 ID"},{name:"activity",default:"",help:"活动 ID"},{name:"poi_id",default:"",help:"地理位置 ID"},{name:"poi_name",default:"",help:"地理位置名称"},{name:"hotspot",default:"",help:"关联热点词"},{name:"no_safety_check",type:"bool",default:!1,help:"跳过内容安全检测"},{name:"sync_toutiao",type:"bool",default:!1,help:"同步发布到头条"},{name:"ai-generated",type:"boolean",help:"声明内容由 AI 生成(可选,随定时发布请求提交)"},...k],columns:["status","aweme_id","url","publish_time"],func:async(o,e)=>{const m=ee(e);if(!!e.immediate===!!e.schedule)throw new _("必须指定 --schedule 或 --immediate,且只能选择一个");const r=v.resolve(e.video);if(!T.existsSync(r))throw new _(`视频文件不存在: ${r}`);const f=v.extname(r).toLowerCase();if(![".mp4",".mov",".avi",".webm"].includes(f))throw new _(`不支持的视频格式: ${f}(支持 mp4/mov/avi/webm)`);const p=T.statSync(r).size,s=e.title;if(s.length>30)throw new _("标题不能超过 30 字");const n=e.caption||"";if(n.length>1000)throw new _("正文不能超过 1000 字");const x=e.immediate?0:de(e.schedule);if(!e.immediate)le(x);const H=_e[e.visibility]??0,U=e.cover;if(U){if(!T.existsSync(v.resolve(U)))throw new _(`封面文件不存在: ${v.resolve(U)}`)}const O=await ie(o),D=await se(p,O);let w="",g=720,E=1280;await re({filePath:r,uploadInfo:D,credentials:O,onProgress:(t,i)=>{const a=Math.round(t/i*100);process.stderr.write(`\r 上传进度: ${a}%`)}});process.stderr.write(`
|
|
2
2
|
`);process.stderr.write(` 提交上传...
|
|
3
|
-
`);const
|
|
4
|
-
`);
|
|
5
|
-
fetch(${JSON.stringify(
|
|
3
|
+
`);const h=await ne(D,O),A=h.video_id;process.stderr.write(` 上传已提交: ${A}
|
|
4
|
+
`);g=h.width||g;E=h.height||E;if(!w&&h.poster_uri)w=h.poster_uri;if(e.cover){const t=v.resolve(e.cover),i=`${z}/?Action=ApplyImageUpload&ServiceId=${L}&Version=2018-08-01&UploadNum=1`,a=`fetch(${JSON.stringify(i)}, { credentials: 'include' }).then(r => r.json())`,y=M(await o.evaluate(a),"抖音封面申请上传地址响应异常");j("抖音封面申请上传地址",y);const c=y.Result?.UploadAddress?.StoreInfos?.[0];if(!c?.UploadHost||!c?.StoreUri)throw new u(`抖音封面申请上传地址响应缺少 UploadHost/StoreUri: ${JSON.stringify(y).slice(0,500)}`);const b=`https://${c.UploadHost}/${c.StoreUri}`,l=await ae(t,{upload_url:b,store_uri:c.StoreUri}),I=`${z}/?Action=CommitImageUpload&ServiceId=${L}&Version=2018-08-01`,d=JSON.stringify({SuccessObjKeys:[l]}),Z=`
|
|
5
|
+
fetch(${JSON.stringify(I)}, {
|
|
6
6
|
method: 'POST',
|
|
7
7
|
credentials: 'include',
|
|
8
8
|
headers: { 'Content-Type': 'application/json' },
|
|
9
9
|
body: ${JSON.stringify(d)}
|
|
10
10
|
}).then(r => r.json())
|
|
11
|
-
`,
|
|
12
|
-
`);const
|
|
13
|
-
`);else throw new
|
|
14
|
-
|
|
11
|
+
`,Q=M(await o.evaluate(Z),"抖音封面提交上传响应异常");j("抖音封面提交上传",Q);w=l}if(!e.no_safety_check){const t="https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check",i={video_id:A,title:s,desc:n},a=await B(o,"POST",t,{body:i});if(!a.ok)process.stderr.write(` 内容安全预检接口无响应,继续轮询检测结果。
|
|
12
|
+
`);const y="https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll",c=Date.now()+30000;let b=!1,l=0;while(Date.now()<c){const I=await B(o,"POST",y,{body:i});if(!I.ok){l+=1;if(!a.ok&&l>=3)break;await C(2000);continue}l=0;const d=I.value;if(d.status===0||d.has_done===!0&&d.detect_result?.reason_code===0&&(d.detect_list?.length??0)===0){b=!0;break}if(d.status===1)throw new u("内容安全检测不通过,请修改后重试","使用 --no_safety_check 跳过");await C(2000)}if(!b)if(!a.ok&&l>=3)process.stderr.write(` 内容安全预检持续无响应,跳过本地预检,交由 create_v2 后的平台审核。
|
|
13
|
+
`);else throw new u("内容安全检测超时(30s),请稍后重试","如确认要跳过本地预检,可使用 --no_safety_check;提交后仍会走抖音平台审核")}const q=pe(n),J=[];let P=0;for(const t of q){const i=n.indexOf(`#${t}`,P);if(i===-1)continue;J.push({name:t,id:0,start:i,end:i+t.length+1});P=i+t.length+1}const R=n?`${s} ${n}`:s,F=n?s.length+1:0,G=me(R,J.map((t)=>({...t,start:t.start+F,end:t.end+F}))),K={item:{common:{text:R,caption:n,item_title:s,activity:JSON.stringify(e.activity?[e.activity]:[]),text_extra:JSON.stringify(G),challenges:"[]",mentions:"[]",hashtag_source:"",hot_sentence:e.hotspot||"",interaction_stickers:"[]",visibility_type:H,download:e.allow_download?1:0,timing:x,creation_id:ce(),media_type:4,video_id:A,music_source:0,music_id:null,...e.poi_id?{poi_id:e.poi_id,poi_name:e.poi_name}:{}},cover:{poster:w,custom_cover_image_height:E,custom_cover_image_width:g,poster_delay:0,cover_tools_info:fe,cover_tools_extend_info:"{}"},mix:e.collection?{mix_id:e.collection,mix_order:0}:{},chapter:{chapter:JSON.stringify({chapter_abstract:"",chapter_details:[],chapter_type:0})},anchor:{},sync:{should_sync:!1,sync_to_toutiao:e.sync_toutiao?1:0},open_platform:{},assistant:{is_preview:0,is_post_assistant:1},declare:{user_declare_info:m}}},W=`https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${ue}`;process.stderr.write(e.immediate?` 创建发布...
|
|
14
|
+
`:` 创建定时发布...
|
|
15
|
+
`);let S;try{S=await V(o,"POST",W,{body:K})}catch(t){if((t instanceof Error?t.message:String(t)).includes("Failed to fetch"))throw new u("提交发布请求被浏览器侧拦截(疑似字节安全组件 secsdk)","不要直接重试:先用 `ppcli douyin videos` / 创作者中心确认作品是否已创建,避免重复投稿。"+"确认未创建后,用 `--window foreground` 前台窗口重试(视频已上传成功的部分无需重传)。");throw t}const N=S.aweme_id??S.item_id;if(!N)throw new u(`发布成功但未返回 aweme_id/item_id: ${JSON.stringify(S)}`);const X=`https://www.douyin.com/video/${N}`,Y=e.immediate?"立即发布":new Date(x*1000).toLocaleString("zh-CN",{timeZone:"Asia/Shanghai"});return[{status:e.immediate?"✅ 发布请求已提交":"✅ 定时发布成功!",aweme_id:N,url:X,publish_time:Y}]}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CommandExecutionError as d}from"@jackwener/opencli/errors";export function xhsAiDeclarationStep(r,l="笔记含AI合成内容"){const c=(e)=>e.getClientRects().length>0&&getComputedStyle(e).visibility!=="hidden",o=(e)=>(e.textContent||"").replace(/\s+/g,""),n=[...document.querySelectorAll(".d-select")].filter(c);if(n.filter((e)=>o(e.querySelector(".d-select-description")||e)===l).length===1)return{selected:!0};if(r==="verify")return{selected:!1};if(r==="open"){const e=n.filter((s)=>o(s.querySelector(".d-select-placeholder")||s)==="添加内容类型声明");if(e.length!==1)return{error:"未找到唯一的内容类型声明控件,或已有其他声明"};return{selector:".d-select",nth:[...document.querySelectorAll(".d-select")].indexOf(e[0])}}const t=[...document.querySelectorAll(".declaration-drop-down .d-option-content")].filter((e)=>c(e)&&o(e)===l);if(t.length!==1)return{error:"未找到唯一的“笔记含AI合成内容”选项"};return{selector:".declaration-drop-down .d-option-content",nth:[...document.querySelectorAll(".declaration-drop-down .d-option-content")].indexOf(t[0])}}export async function setXhsAiDeclaration(r,l="笔记含AI合成内容"){const c=(t)=>r.evaluate(`(${xhsAiDeclarationStep.toString()})(${JSON.stringify(t)}, ${JSON.stringify(l)})`),o=(t)=>{throw new d(`小红书 AI 内容声明设置失败:${t};已停止提交`)},n=await c("open");if(n?.selected)return;if(!n?.selector)o(n?.error||"声明控件响应异常");await r.click(n.selector,{nth:n.nth});await r.wait({time:0.2});const i=await c("select");if(i?.selected)return;if(!i?.selector)o(i?.error||"声明选项响应异常");await r.click(i.selector,{nth:i.nth});for(let t=0;t<10;t++){if((await c("verify"))?.selected)return;await r.wait({time:0.2})}o("选择后未回显“笔记含AI合成内容”")}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import{ArgumentError as v,CommandExecutionError as c}from"@jackwener/opencli/errors";import{commentId as p,commentNote as C,commentSnapshot as h,locateComment as _,openCommentPage as I,readCommentPage as T,reloadCommentPage as E}from"./comment-state.js";const g="#content-textarea",q=".engage-bar.active button.submit",R=".engage-bar.active button.cancel",S="The action may already have succeeded. Read comments/replies to verify before retrying; this command did not resubmit.";async function b(t,r,a){if(await t.evaluate(`(() => {
|
|
2
|
+
const state = (${T.toString()})(${JSON.stringify(r)});
|
|
3
|
+
if (state.error) return false;
|
|
4
|
+
const controls = document.querySelectorAll(${JSON.stringify(a)});
|
|
5
|
+
if (controls.length !== 1 || controls[0].disabled || !controls[0].getClientRects().length) return false;
|
|
6
|
+
controls[0].click();
|
|
7
|
+
return true;
|
|
8
|
+
})()`)!==!0)throw new c("Exact comment control is unavailable, disabled, or the note changed")}function s(t){return String(t).replace(/\r\n/g,`
|
|
9
|
+
`).replace(/\u00a0/g," ").trim()}function N(t,r,a){const e=t.editor;if(!e?.active||!e.canSend||s(e.text)!==s(a))throw new c("Comment draft did not retain the exact text or the send button is disabled");if(r?e.replyAuthor!==`回复 ${r.author}`||s(e.replyText)!==s(r.text):e.replyAuthor!==""||e.replyText!=="")throw new c("Reply target in the composer does not match the requested comment")}export const writeArgs=[{name:"root-id",help:"Top-level root_id from comments; use when targeting a nested reply"},{name:"execute",type:"boolean",default:!1,help:"Submit after verification (default: dry-run only)"}],writeColumns=["status","note_id","id","root_id","parent_id","text","url"];export async function runComment(t,r){const{url:a,noteId:e}=C(r["note-id"]),n=r.text;if(typeof n!=="string"||!n.trim())throw new v("Comment text must not be empty");const u=r["reply-to"]==null?null:p(r["reply-to"],"reply-to"),m=r["root-id"]==null?null:p(r["root-id"],"root-id");if(m&&!u)throw new v("--root-id requires --reply-to (or the reply command)");let i=await I(t,a,e);if(i.editor?.text?.trim())throw new c("Existing comment draft is not empty; refusing to overwrite it");let d=null;if(u)({state:i,target:d}=await _(t,e,u,m));if(!i.selfId)throw new c("Cannot identify the current account for comment verification");const y=i.rows.filter((o)=>o.userId===i.selfId&&o.parent_id===u&&s(o.text)===s(n));if(y.length){const o=y[0];return[{status:"already-present",note_id:e,id:o.id,root_id:o.root_id,parent_id:o.parent_id,text:o.text,url:a}]}const k=new Set(i.rows.map((o)=>o.id));if(d)await b(t,e,`#comment-${d.id} .reply`);else if(!i.editor?.active)await t.click(".engage-bar:has(#content-textarea) .not-active");let x=!1;for(let o=0;o<10;o++){await t.wait({time:0.2});if((await h(t,e)).editor?.active){x=!0;break}}if(!x)throw new c("Comment composer did not open");await t.typeText(g,n);await t.wait({time:0.2});i=await h(t,e);N(i,d,n);if(!r.execute){await b(t,e,R);let o=!1;for(let l=0;l<10;l++){await t.wait({time:0.3});const w=await h(t,e);if(w.editor?.text===""&&!w.editor.active){o=!0;break}}if(!o)throw new c("Dry-run draft was verified but could not be cleared");return[{status:"dry-run",note_id:e,id:null,root_id:d?.root_id??null,parent_id:u,text:n,url:a}]}N(await h(t,e),d,n);try{await b(t,e,q);let o;for(let w=0;w<15;w++){await t.wait({time:0.5});o=(await h(t,e)).rows.find((f)=>!k.has(f.id)&&f.userId===i.selfId&&f.parent_id===u&&(!d||f.root_id===d.root_id)&&s(f.text)===s(n));if(o)break}if(!o)throw Error("No new matching comment appeared");await E(t,e);const{target:l}=await _(t,e,o.id,d?.root_id??null);if(l.userId!==i.selfId||l.parent_id!==u||s(l.text)!==s(n))throw Error("Persisted comment does not match the requested author, text and reply target");return[{status:"posted",note_id:e,id:l.id,root_id:l.root_id,parent_id:l.parent_id,text:l.text,url:a}]}catch(o){throw new c(`Comment submission could not be confirmed: ${o.message}`,S)}}export async function runCommentLike(t,r){const{url:a,noteId:e}=C(r["note-id"]),n=p(r["comment-id"]),u=r["root-id"]==null?null:p(r["root-id"],"root-id"),m=!r.undo;await I(t,a,e);const{target:i}=await _(t,e,n,u),d=(o)=>[{status:o,note_id:e,id:n,root_id:i.root_id,liked:m,url:a}];if(i.liked===m)return d(m?"already-liked":"already-unliked");const y=`#comment-${n} .like-wrapper`;if(await t.evaluate(`document.querySelectorAll(${JSON.stringify(y)}).length`)!==1)throw new c("Exact comment like control is unavailable or ambiguous");if(!r.execute)return[{status:"dry-run",note_id:e,id:n,root_id:i.root_id,liked:i.liked,url:a}];const x=(await h(t,e)).rows.find((o)=>o.id===n);if(!x||x.liked!==i.liked)throw new c("Comment like state changed before the click; stopped");try{await b(t,e,y);let o=!1;for(let w=0;w<10;w++){await t.wait({time:0.5});if((await h(t,e)).rows.find((f)=>f.id===n)?.liked===m){o=!0;break}}if(!o)throw Error("Like state did not change");await E(t,e);const{target:l}=await _(t,e,n,i.root_id);if(l.liked!==m)throw Error("Like state did not persist after reload");return d(m?"liked":"unliked")}catch(o){throw new c(`Comment like could not be confirmed: ${o.message}`,S)}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ArgumentError as h,AuthRequiredError as b,CommandExecutionError as f,EmptyResultError as g}from"@jackwener/opencli/errors";import{buildNoteUrl as _,parseNoteId as C}from"../note-helpers.js";export function commentId(e,o="comment-id"){if(typeof e!=="string"||!/^[a-f0-9]{24}$/i.test(e))throw new h(`${o} must be a 24-character comment ID from comments/replies`);return e.toLowerCase()}export function commentNote(e){const o=_(String(e??""),{commandName:"xiaohongshu comment operations"}),t=new URL(o);if(t.protocol!=="https:"||t.hostname!=="www.xiaohongshu.com"||t.port||t.username||t.password)throw new h("Comment operations require an https://www.xiaohongshu.com signed note URL");return{url:o,noteId:commentId(C(o),"note-id")}}export function readCommentPage(e){const o=document.querySelector("#app")?.__vue_app__?.config?.globalProperties?.$pinia,t=o?._s?.get("note"),n=o?._s?.get("user"),d=location.pathname.match(/\/(?:explore|note|search_result|discovery\/item)\/([a-f0-9]+)\/?$/i)?.[1]||location.pathname.match(/\/user\/profile\/[^/]+\/([a-f0-9]+)\/?$/i)?.[1];if(location.hostname!=="www.xiaohongshu.com"||location.protocol!=="https:")return{error:"wrong_host"};if(/website-login\/error|error_code=300017|error_code=300031/.test(location.href))return{error:"security_block"};if(n?.loggedIn===!1||/\/login(?:\/|$)/.test(location.pathname))return{error:"login"};if(d!==e||t?.currentNoteId!==e)return{error:"wrong_note"};const s=t.noteDetailMap?.[e]?.comments;if(!Array.isArray(s?.list)||typeof s.hasMore!=="boolean")return{error:"comments_unavailable"};const l=[],p=(r,m)=>{const a=document.getElementById("comment-"+r.id),w=m?.id||r.id,u=m?null:Number(r.subCommentCount);l.push({id:r.id,root_id:w,parent_id:m?r.targetComment?.id??null:null,author:r.userInfo?.nickname??"",userId:r.userInfo?.userId??"",profileUrl:r.userInfo?.userId?"https://www.xiaohongshu.com/user/profile/"+r.userInfo.userId:"",text:r.content,likes:Number(r.likeCount),liked:r.liked,time:a?.querySelector(".date, .time")?.textContent?.trim()??"",is_reply:Boolean(m),reply_to:m?r.targetComment?.userInfo?.nickname??"":"",reply_count:u,replies_complete:m?null:Number.isInteger(u)&&new Set((r.subComments||[]).map((x)=>x.id)).size>=u,rendered:Boolean(a)})};for(const r of s.list){p(r,null);for(const m of r.subComments||[])p(m,r)}const i=document.querySelector("#content-textarea"),c=i?.closest(".engage-bar"),y=Array.from(document.querySelectorAll('a[href*="/user/profile/"]')).find((r)=>r.textContent.trim()==="我");return{noteId:e,rows:l,hasMore:s.hasMore,loading:s.loading===!0,selfId:y?.getAttribute("href")?.match(/^\/user\/profile\/([a-f0-9]{24})(?:[/?]|$)/i)?.[1]??null,editor:i?{text:i.innerText??i.textContent,replyAuthor:c?.querySelector(".reply-content .reply")?.textContent?.trim()??"",replyText:c?.querySelector(".reply-content .content")?.textContent??"",active:c?.classList.contains("active")===!0,canSend:c?.querySelector("button.submit")?.disabled===!1}:null}}export function snapshotScript(e){return`(${readCommentPage.toString()})(${JSON.stringify(e)})`}export async function commentSnapshot(e,o){const t=await e.evaluate(snapshotScript(o));if(t?.error==="login")throw new b("www.xiaohongshu.com");if(!t||t.error||t.noteId!==o||!Array.isArray(t.rows)||typeof t.hasMore!=="boolean")throw new f(`Cannot inspect the requested comment page: ${t?.error||"malformed state"}`);for(const n of t.rows)if(!/^[a-f0-9]{24}$/i.test(n.id)||!/^[a-f0-9]{24}$/i.test(n.root_id)||n.is_reply&&!/^[a-f0-9]{24}$/i.test(n.parent_id)||typeof n.text!=="string"||typeof n.liked!=="boolean"||!Number.isInteger(n.likes)||n.likes<0)throw new f("Malformed comment identity/content/state; refusing to operate");return t}export async function openCommentPage(e,o,t){await e.goto(o);await e.wait({time:3});return commentSnapshot(e,t)}export async function reloadCommentPage(e,o){await e.cdp("Page.reload",{});await e.wait({time:3});return commentSnapshot(e,o)}export async function expandCommentThread(e,o,t,n=50,d=null){for(let s=0;s<50;s++){const l=await commentSnapshot(e,o),p=l.rows.find((a)=>a.id===t&&!a.is_reply);if(!p)throw new g("xiaohongshu replies","Root comment is not loaded");const i=l.rows.filter((a)=>a.is_reply&&a.root_id===t);if(d?i.some((a)=>a.id===d&&a.rendered):i.length>=n)return l;if(p.replies_complete)return l;const c=`.parent-comment:has(#comment-${t}) .show-more`;if(!await e.evaluate(`(() => { const e = document.querySelector(${JSON.stringify(c)}); return !!e && e.getClientRects().length > 0; })()`))throw new f("Reply list is incomplete but its expand control is unavailable");if(await e.evaluate(`(() => { const es = document.querySelectorAll(${JSON.stringify(c)}); if (es.length !== 1) return false; es[0].click(); return true; })()`)!==!0)throw new f("Reply expand control became ambiguous or disappeared");let m=!1;for(let a=0;a<10;a++){await e.wait({time:0.3});const w=await commentSnapshot(e,o);if(w.rows.filter((u)=>u.is_reply&&u.root_id===t).length>i.length||w.rows.find((u)=>u.id===t)?.replies_complete){m=!0;break}}if(!m)throw new f("Expanding replies produced no progress; stopped without repeating the click")}throw new f("Reply loading exceeded its bounded limit")}export async function locateComment(e,o,t,n=null){for(let d=0;d<50;d++){let s=await commentSnapshot(e,o);const l=s.rows.find((i)=>i.id===t&&i.rendered);if(l){if(n&&l.root_id!==n)throw new h("comment-id does not belong to root-id");return{state:s,target:l}}if(n&&s.rows.some((i)=>i.id===n&&!i.is_reply)){s=await expandCommentThread(e,o,n,50,t);const i=s.rows.find((c)=>c.id===t&&c.root_id===n&&c.rendered);if(i)return{state:s,target:i};break}if(!s.hasMore&&!s.loading)break;if(s.rows.filter((i)=>!i.is_reply).length>=50)break;await e.evaluate("(() => { const es = document.querySelectorAll('.parent-comment'); es[es.length - 1]?.scrollIntoView({block:'end'}); })()");await e.wait({time:1})}throw new g("xiaohongshu comment","Exact comment not found within 50 top-level comments. For a nested reply, supply its root_id via --root-id.")}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import{ArgumentError as s,AuthRequiredError as u,CommandExecutionError as n}from"@jackwener/opencli/errors";const a="https://creator.xiaohongshu.com",c="https://edith.xiaohongshu.com",p={topics:["post",a+"/api/galaxy/v2/creator/servicegw/v2/search/topics"],activities:["get","EVENTS_LIST"],location:["post",c+"/web_api/sns/v1/local/poi/creator/search"],collections:["post",c+"/api/sns/v1/note/collection/pc/list_v2"],groups:["get",c+"/api/im/web/nns/group_list"]};export function discoveryLimit(t=20){if(!Number.isInteger(t)||t<1||t>100)throw new s("--limit 必须为 1 至 100 的整数");return t}export function listField(t,e){if(!t||!Array.isArray(t[e]))throw new n(`发布候选响应缺少 ${e} 数组,不能视为空列表`);return t[e]}export async function prepareCreatorDiscovery(t){await t.goto(a+"/publish/publish?from=homepage&target=image");for(let e=0;e<30;e++){const o=await t.evaluate(`({ready:!!window.webpackChunkugc && !!document.querySelector('input[type="file"]'),login:/\\/login(?:[/?]|$)/.test(location.href) || !!document.querySelector('input[type="password"]')})`);if(o?.login)throw new u("creator.xiaohongshu.com","请先登录小红书创作服务平台");if(o?.ready)return;await t.wait({time:0.5})}throw new n("小红书发布查询运行时未就绪")}export async function creatorDiscoveryRequest(t,e,o){const i=p[e];if(!i)throw new s("未知发布候选类型: "+e);const r=await t.evaluate(`(async ({method,url,params})=>{
|
|
2
|
+
try {
|
|
3
|
+
let runtime;
|
|
4
|
+
if (!window.webpackChunkugc) throw new Error('创作者运行时未加载');
|
|
5
|
+
window.webpackChunkugc.push([['ppcli-discovery-'+Date.now()],{},req=>{runtime=req}]);
|
|
6
|
+
// Identify the native HTTP client by its exported contract, not a
|
|
7
|
+
// build-specific numeric module ID. Do not execute unrelated modules.
|
|
8
|
+
const modules=Object.entries(runtime.m).filter(([,factory])=>{
|
|
9
|
+
const source=factory.toString();
|
|
10
|
+
return source.includes('provideUseHttp') && source.includes('LV:') && source.includes('vF:');
|
|
11
|
+
});
|
|
12
|
+
if(modules.length!==1) throw new Error('无法定位唯一的创作者 HTTP 客户端');
|
|
13
|
+
const client=runtime(modules[0][0]).LV;
|
|
14
|
+
if(typeof client?.get!=='function'||typeof client?.post!=='function') throw new Error('创作者 HTTP 客户端接口已变化');
|
|
15
|
+
const options={transform:true,withCredentials:true,timeout:20000};
|
|
16
|
+
const data=method==='get' ? await client.get(url,{...options,params}) : await client.post(url,params,options);
|
|
17
|
+
return {ok:true,data};
|
|
18
|
+
} catch(error) {return {ok:false,error:String(error?.message||error),code:error?.code};}
|
|
19
|
+
})(${JSON.stringify({method:i[0],url:i[1],params:o})})`);if(!r?.ok){if([401,403].includes(Number(r?.code)))throw new u("creator.xiaohongshu.com",r.error);throw new n(`小红书 ${e} 查询失败:${r?.error||"无响应"}${r?.code!==void 0?" (code="+r.code+")":""}`)}return r.data}export const activityQuery={sort:"1",type:"1",source:3,topicActivity:0};
|