publishport-opencli 1.0.6 → 1.0.8
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 +47 -4
- package/clis/chatgpt/auth.js +2 -2
- package/clis/chatgpt/image.js +1 -1
- package/clis/chatgpt/utils.js +1 -1
- package/clis/instagram/_shared/private-publish.js +4 -4
- package/clis/instagram/explore.js +37 -12
- package/clis/instagram/post.js +20 -20
- package/clis/rednote/comments.js +1 -1
- package/clis/toutiao/hot.js +1 -1
- package/clis/toutiao/recommend.js +1 -0
- package/clis/toutiao/utils.js +2 -2
- package/clis/twitter/article.js +53 -12
- package/clis/twitter/profile.js +1 -1
- package/clis/xiaohongshu/comments.js +57 -14
- package/clis/xiaohongshu/human.js +14 -0
- package/clis/xiaohongshu/publish.js +63 -44
- package/clis/zhihu/paginate.js +2 -2
- package/dist/src/browser/managed-chrome.js +1 -1
- package/dist/src/plugin.js +1 -1
- package/dist/src/utils.d.ts +1 -1
- package/dist/src/utils.js +2 -5
- package/package.json +1 -1
package/clis/toutiao/utils.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{ArgumentError as
|
|
2
|
-
`).map((
|
|
1
|
+
import{ArgumentError as Z,CommandExecutionError as B}from"@jackwener/opencli/errors";const U=1,D=4,H=1,y=50,P=1,X=50;export function parseArticlesPage(q,z=1){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--page must be an integer between ${U} and ${D}, got ${JSON.stringify(q)}`);if(J<U||J>D)throw new Z(`--page must be between ${U} and ${D}, got ${J}`);return J}export function parseRecommendLimit(q,z=20){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--limit must be an integer between ${P} and ${X}, got ${JSON.stringify(q)}`);if(J<P||J>X)throw new Z(`--limit must be between ${P} and ${X}, got ${J}`);return J}export function parseRecommendCategory(q,z="__all__"){if(q===void 0||q===null||q==="")return z;const J=String(q).trim();if(!RECOMMEND_CATEGORIES.includes(J))throw new Z(`--category must be one of ${RECOMMEND_CATEGORIES.join(", ")}, got ${JSON.stringify(q)}`);return J}export function parseHotLimit(q,z=30){if(q===void 0||q===null||q==="")return z;const J=Number(q);if(!Number.isFinite(J)||!Number.isInteger(J))throw new Z(`--limit must be an integer between ${H} and ${y}, got ${JSON.stringify(q)}`);if(J<H||J>y)throw new Z(`--limit must be between ${H} and ${y}, got ${J}`);return J}const b=new Set(["展现","阅读","点赞","评论","查看数据","查看评论","修改","更多","首发","已发布","定时发布","定时发布中","由文章生成","审核中"]),g=/展现\s*([\d,]+)\s*阅读\s*([\d,]+)\s*点赞\s*([\d,]+)\s*评论\s*([\d,]*)/;export function parseToutiaoArticlesText(q){const z=String(q||"").split(`
|
|
2
|
+
`).map((K)=>K.trim()).filter(Boolean),J=[];for(let K=0;K<z.length;K++){const $=z[K];if(!/^\d{2}-\d{2}\s+\d{2}:\d{2}$/.test($))continue;const W=$;let F=null,h=null,k=null;for(let Y=3;Y>=1;Y--){const Q=z[K-Y]||"";if(!Q||Q.length>=100||/^\d+$/.test(Q)||b.has(Q))continue;F=Q;break}for(let Y=1;Y<8;Y++){const Q=z[K+Y]||"";if(Q==="已发布"||Q==="定时发布中"||Q==="审核中"||Q==="由文章生成")h=Q;if(Q.includes("展现")&&Q.includes("阅读")){const j=Q.match(g);if(j)k={"展现":j[1],"阅读":j[2],"点赞":j[3],"评论":j[4]||"0"}}}if(!F)continue;if(k)J.push({title:F,date:W,status:h,...k});else J.push({title:F,date:W,status:h,"展现":null,"阅读":null,"点赞":null,"评论":null})}return J}function V(q){const z=String(q??"").trim();return z?z:null}function v(q){const z=q?.Image?.url;if(typeof z==="string"&&z)return z;return(Array.isArray(q?.Image?.url_list)?q.Image.url_list.map((K)=>typeof K==="string"?K:K?.url).find((K)=>typeof K==="string"&&K):null)||null}function S(q){const z=Number(q);return Number.isFinite(z)&&z>=0?z:null}export function mapHotRow(q,z){if(!q||typeof q!=="object")return null;const J=V(q.ClusterIdStr||(q.ClusterId!=null?String(q.ClusterId):null)),K=V(q.Title);if(!K)return null;return{rank:z+1,group_id:J,title:K,query:V(q.QueryWord)||K,hot_value:S(q.HotValue),label:V(q.Label),url:V(q.Url),image_url:v(q)}}export const HOT_BOARD_URL="https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc",RECOMMEND_URL="https://www.toutiao.com/api/pc/feed/",RECOMMEND_CATEGORIES=["__all__","news_tech","news_finance","news_world","news_sports","news_entertainment","news_military"];function G(q){const z=V(q?.image_url)||V(q?.middle_image?.url);if(!z)return null;return z.startsWith("//")?`https:${z}`:z}function x(q){const z=V(q);if(!z)return null;try{const J=z.startsWith("/")?new URL(z,"https://www.toutiao.com"):new URL(z);if(!/^https?:$/i.test(J.protocol)||!/(^|\.)toutiao\.com$/i.test(J.hostname))throw new B(`Malformed toutiao recommend row: off-domain source_url ${JSON.stringify(z)}.`);return J}catch(J){if(J instanceof B)throw J;throw new B(`Malformed toutiao recommend row: invalid source_url ${JSON.stringify(z)}.`)}}function R(q){return String(q||"").match(/^\/(?:group|article)\/([A-Za-z0-9_-]+)\/?$/)?.[1]||""}function f(q,z){const J=x(q),K=J?R(J.pathname):"",$=V(z),W=$||V(K);if(!W)throw new B("Malformed toutiao recommend row: missing group_id and article source_url.");if(!/^[A-Za-z0-9_-]+$/.test(W))throw new B(`Malformed toutiao recommend row: invalid group_id ${JSON.stringify(z)}.`);if(K&&$&&$!==K)throw new B(`Malformed toutiao recommend row: group_id/source_url mismatch for ${JSON.stringify(z)}.`);return{groupId:W,url:`https://www.toutiao.com/group/${W}/`}}function C(q){const z=Number(q);if(!Number.isFinite(z)||z<=0)return null;return`${new Date(z*1000).toISOString().slice(0,19)}Z`}export function mapRecommendRow(q,z){if(!q||typeof q!=="object")return null;if(q.is_feed_ad)return null;const J=V(q.title);if(!J)return null;const K=f(q.source_url,q.group_id!=null?String(q.group_id):null);return{rank:z+1,group_id:K.groupId,title:J,abstract:V(q.abstract),source:V(q.source),tag:V(q.chinese_tag),comments:S(q.comments_count),published_at:C(q.behot_time),url:K.url,image_url:G(q)}}export function looksToutiaoAuthWallText(q){const z=String(q||"").replace(/\s+/g," ").trim().toLowerCase();if(!z)return!1;return/登录|请登录|账号登录|扫码登录|安全验证|验证码|captcha/.test(z)||/\b(login|sign in|captcha|verification required)\b/.test(z)||/mp\.toutiao\.com\/profile_v4\/login/.test(z)}export const __test__={ARTICLES_MIN_PAGE:U,ARTICLES_MAX_PAGE:D,HOT_MIN_LIMIT:H,HOT_MAX_LIMIT:y,RECOMMEND_MIN_LIMIT:P,RECOMMEND_MAX_LIMIT:X};
|
package/clis/twitter/article.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{AuthRequiredError as Z,CommandExecutionError as
|
|
1
|
+
import{AuthRequiredError as Z,CommandExecutionError as H}from"@jackwener/opencli/errors";import{cli as N,Strategy as P}from"@jackwener/opencli/registry";import{resolveTwitterQueryId as b,describeTwitterApiError as k,unwrapBrowserResult as $}from"./shared.js";import{TWITTER_BEARER_TOKEN as B}from"./utils.js";const D="7xflPyRiUxGVbJd4uWmbfg";function M(f){return Boolean(f&&typeof f==="object"&&!Array.isArray(f))}N({site:"twitter",name:"article",access:"read",description:"Fetch a Twitter Article (long-form content) and export as Markdown",domain:"x.com",strategy:P.COOKIE,browser:!0,args:[{name:"tweet-id",type:"string",positional:!0,required:!0,help:"Tweet ID or URL containing the article"}],columns:["title","author","content","url"],func:async(f,j)=>{let F=j["tweet-id"];const K=/\/article\/\d+/.test(F),V=F.match(/\/(?:status|article)\/(\d+)/);if(V)F=V[1];if(K){await f.goto(`https://x.com/i/article/${F}`);await f.wait(3);const G=await f.evaluate(`
|
|
2
2
|
(function() {
|
|
3
3
|
var links = document.querySelectorAll('a[href*="/status/"]');
|
|
4
4
|
for (var i = 0; i < links.length; i++) {
|
|
@@ -12,12 +12,12 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
|
|
|
12
12
|
}
|
|
13
13
|
return null;
|
|
14
14
|
})()
|
|
15
|
-
`);if(!
|
|
15
|
+
`),J=$(G);if(!J||typeof J!=="string")throw new H(`Could not resolve article ${F} to a tweet ID. The article page may not contain a linked tweet.`);F=J}await f.goto(`https://x.com/i/status/${F}`);await f.wait(3);const X=(await f.getCookies({url:"https://x.com"})).find((G)=>G.name==="ct0")?.value||null;if(!X)throw new Z("x.com","Not logged into x.com (no ct0 cookie)");const L=await b(f,"TweetResultByRestId",D),z=$(await f.evaluate(`
|
|
16
16
|
async () => {
|
|
17
|
-
const tweetId =
|
|
18
|
-
const ct0 = ${JSON.stringify(
|
|
17
|
+
const tweetId = ${JSON.stringify(F)};
|
|
18
|
+
const ct0 = ${JSON.stringify(X)};
|
|
19
19
|
|
|
20
|
-
const bearer = ${JSON.stringify(
|
|
20
|
+
const bearer = ${JSON.stringify(B)};
|
|
21
21
|
const headers = {
|
|
22
22
|
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
23
23
|
'X-Csrf-Token': ct0,
|
|
@@ -45,23 +45,54 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
|
|
|
45
45
|
withArticlePlainText: true,
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
const url = '/i/api/graphql/' + ${JSON.stringify(
|
|
48
|
+
const url = '/i/api/graphql/' + ${JSON.stringify(L)} + '/TweetResultByRestId?variables='
|
|
49
49
|
+ encodeURIComponent(variables)
|
|
50
50
|
+ '&features=' + encodeURIComponent(features)
|
|
51
51
|
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
let resp;
|
|
54
|
+
try {
|
|
55
|
+
resp = await fetch(url, {headers, credentials: 'include'});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return {error: 'Twitter article request failed: ' + String(error && error.message || error)};
|
|
58
|
+
}
|
|
54
59
|
if (!resp.ok) return {httpStatus: resp.status};
|
|
55
|
-
|
|
60
|
+
let d;
|
|
61
|
+
try {
|
|
62
|
+
d = await resp.json();
|
|
63
|
+
} catch {
|
|
64
|
+
return {error: 'Twitter API response was not valid JSON', hint: 'You may be logged out or the request was blocked'};
|
|
65
|
+
}
|
|
66
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) {
|
|
67
|
+
return {error: 'Twitter API response payload was malformed'};
|
|
68
|
+
}
|
|
56
69
|
|
|
57
|
-
const result = d
|
|
58
|
-
if (!result)
|
|
70
|
+
const result = d?.data?.tweetResult?.result;
|
|
71
|
+
if (!result) {
|
|
72
|
+
if (Array.isArray(d.errors) && d.errors.length > 0) {
|
|
73
|
+
return {error: 'Twitter TweetResultByRestId returned GraphQL errors: ' + JSON.stringify(d.errors).slice(0, 200)};
|
|
74
|
+
}
|
|
75
|
+
return {error: 'Article not found'};
|
|
76
|
+
}
|
|
59
77
|
|
|
60
78
|
// Unwrap TweetWithVisibilityResults
|
|
79
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
80
|
+
return {error: 'Twitter API response tweet result was malformed'};
|
|
81
|
+
}
|
|
61
82
|
const tw = result.tweet || result;
|
|
83
|
+
if (!tw || typeof tw !== 'object' || Array.isArray(tw)) {
|
|
84
|
+
return {error: 'Twitter API response tweet result was malformed'};
|
|
85
|
+
}
|
|
62
86
|
const legacy = tw.legacy || {};
|
|
63
87
|
const user = tw.core?.user_results?.result;
|
|
64
|
-
const
|
|
88
|
+
const returnedTweetId = tw.rest_id || legacy.id_str;
|
|
89
|
+
if (typeof returnedTweetId !== 'string' || returnedTweetId !== tweetId) {
|
|
90
|
+
return {error: 'Twitter API response did not match requested tweet ' + tweetId};
|
|
91
|
+
}
|
|
92
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
|
|
93
|
+
if (typeof screenName !== 'string' || !/^[A-Za-z0-9_]{1,15}$/.test(screenName)) {
|
|
94
|
+
return {error: 'Twitter API response did not include a valid author screen name for tweet ' + tweetId};
|
|
95
|
+
}
|
|
65
96
|
|
|
66
97
|
// Extract article content
|
|
67
98
|
const articleResults = tw.article?.article_results?.result;
|
|
@@ -78,15 +109,25 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
|
|
|
78
109
|
}
|
|
79
110
|
return {error: 'Tweet ' + tweetId + ' has no article content'};
|
|
80
111
|
}
|
|
112
|
+
if (!articleResults || typeof articleResults !== 'object' || Array.isArray(articleResults)) {
|
|
113
|
+
return {error: 'Twitter API response article result was malformed'};
|
|
114
|
+
}
|
|
81
115
|
|
|
82
116
|
const title = articleResults.title || '(Untitled)';
|
|
83
117
|
const contentState = articleResults.content_state || {};
|
|
118
|
+
if (!contentState || typeof contentState !== 'object' || Array.isArray(contentState)) {
|
|
119
|
+
return {error: 'Twitter API response article content was malformed'};
|
|
120
|
+
}
|
|
84
121
|
const blocks = contentState.blocks || [];
|
|
122
|
+
if (!Array.isArray(blocks)) {
|
|
123
|
+
return {error: 'Twitter API response article blocks were malformed'};
|
|
124
|
+
}
|
|
85
125
|
|
|
86
126
|
// Convert draft.js blocks to Markdown
|
|
87
127
|
const parts = [];
|
|
88
128
|
let orderedCounter = 0;
|
|
89
129
|
for (const block of blocks) {
|
|
130
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
|
|
90
131
|
const blockType = block.type || 'unstyled';
|
|
91
132
|
if (blockType === 'atomic') continue;
|
|
92
133
|
const text = block.text || '';
|
|
@@ -113,4 +154,4 @@ import{AuthRequiredError as Z,CommandExecutionError as G}from"@jackwener/opencli
|
|
|
113
154
|
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
|
114
155
|
}];
|
|
115
156
|
}
|
|
116
|
-
`);if(z
|
|
157
|
+
`));if(!Array.isArray(z)&&!M(z))throw new H("Twitter article response payload is malformed");if(z?.httpStatus){const G=k("TweetResultByRestId",z.httpStatus);if(z.httpStatus===401||z.httpStatus===403)throw new Z("x.com",G);throw new H(G)}if(z?.error)throw new H(z.error+(z.hint?` (${z.hint})`:""));if(!Array.isArray(z))throw new H("Twitter article response payload is malformed");return z}});
|
package/clis/twitter/profile.js
CHANGED
|
@@ -3,7 +3,7 @@ import{ArgumentError as k,AuthRequiredError as z,CommandExecutionError as M,Empt
|
|
|
3
3
|
return link ? link.getAttribute('href') : null;
|
|
4
4
|
}`));if(!D||typeof D!=="string")throw new z("x.com","Could not detect logged-in user. Are you logged in?");Z=Q(D);if(!Z)throw new z("x.com","Could not detect logged-in user. Are you logged in?")}await G.goto(`https://x.com/${Z}`);await G.wait(3);const $=(await G.getCookies({url:"https://x.com"})).find((D)=>D.name==="ct0")?.value||null;if(!$)throw new z("x.com","Not logged into x.com (no ct0 cookie)");const L=await b(G,"UserByScreenName",S),X=Y(await G.evaluate(`
|
|
5
5
|
async () => {
|
|
6
|
-
const screenName =
|
|
6
|
+
const screenName = ${JSON.stringify(Z)};
|
|
7
7
|
const ct0 = ${JSON.stringify($)};
|
|
8
8
|
|
|
9
9
|
const bearer = ${JSON.stringify(x)};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import{cli as
|
|
1
|
+
import{cli as F,Strategy as U}from"@jackwener/opencli/registry";import{AuthRequiredError as j,CliError as T,CommandExecutionError as Z,EmptyResultError as _}from"@jackwener/opencli/errors";import{parseNoteId as y,buildNoteUrl as P}from"./note-helpers.js";const L='.author-wrapper a[href*="/user/profile/"], a.name[href*="/user/profile/"], a.user-name[href*="/user/profile/"], a[href*="/user/profile/"]';export function parseXhsProfileHref(G,D="www.xiaohongshu.com"){const B=typeof G==="string"?G.trim():"";if(!B)return"";const Q=String(D||"www.xiaohongshu.com").toLowerCase();let K;try{K=new URL(B,`https://${Q}`)}catch{return""}if(K.protocol!=="https:")return"";if(K.hostname.toLowerCase()!==Q)return"";return K.pathname.match(/^\/user\/profile\/([a-zA-Z0-9]+)\/?$/)?.[1]??""}export function buildXhsProfileUrl(G,D="www.xiaohongshu.com"){const B=parseXhsProfileHref(G,D);if(!B)return"";return`https://${D}/user/profile/${B}`}export function parseCommentLimit(G,D=20){const B=Number(G);if(!Number.isFinite(B))return D;return Math.max(1,Math.min(Math.floor(B),50))}export function parseXhsLikeCountText(G){const D=/^(?:\d+|\d{1,3}(?:[,,]\d{3})+)\+?$/u,B=/^((?:\d+|\d{1,3}(?:[,,]\d{3})+)(?:\.\d+)?)([wWkK万千])\+?$/u,Q=String(G??"").replace(/\s+/g,"");if(!Q)return 0;if(D.test(Q))return Number(Q.replace(/[,+,]/g,""));const K=Q.match(B);if(!K)return 0;const M=Number(K[1].replace(/[,,]/g,""));if(!Number.isFinite(M))return 0;const Y=K[2].toLowerCase();return Math.round(M*(Y==="w"||Y==="万"?1e4:1000))}function $(G,D,B){if(G==null)return"";if(typeof G!=="string")throw new Z(`${B}: malformed comment row ${D}`);return G}export function normalizeCommentImages(G,D){if(G==null)return[];if(!Array.isArray(G))throw new Z(`${D}: malformed comment row images`);const B=[];for(const Q of G){if(typeof Q!=="string")throw new Z(`${D}: malformed comment row image URL`);const K=Q.trim();let M;try{M=new URL(K)}catch{throw new Z(`${D}: malformed comment row image URL`)}if(M.protocol!=="https:"&&M.protocol!=="http:"||M.username||M.password)throw new Z(`${D}: malformed comment row image URL`);const Y=M.toString();if(!B.includes(Y))B.push(Y)}return B}export function normalizeCommentRows(G,D="xiaohongshu/comments"){if(G==null)return[];if(!Array.isArray(G))throw new Z(`${D}: malformed comments payload`);return G.map((B,Q)=>{if(!B||typeof B!=="object"||Array.isArray(B))throw new Z(`${D}: malformed comment row at index ${Q}`);const K=$(B.text,"text",D);if(!K)throw new Z(`${D}: malformed comment row text`);const M=Number(B.likes);if(!Number.isInteger(M)||M<0)throw new Z(`${D}: malformed comment row likes`);if(typeof B.is_reply!=="boolean")throw new Z(`${D}: malformed comment row is_reply`);return{author:$(B.author,"author",D),authorHrefRaw:$(B.authorHrefRaw,"authorHrefRaw",D),text:K,likes:M,time:$(B.time,"time",D),is_reply:B.is_reply,reply_to:$(B.reply_to,"reply_to",D),images:normalizeCommentImages(B.images,D)}})}export function buildCommentsExtractJs(G,D=20){const B=parseXhsLikeCountText.toString();return`
|
|
2
2
|
(async () => {
|
|
3
3
|
const wait = (ms) => new Promise(r => setTimeout(r, ms))
|
|
4
|
-
const withReplies = ${
|
|
4
|
+
const withReplies = ${G}
|
|
5
|
+
const targetCount = ${Number(D)||20}
|
|
5
6
|
|
|
6
7
|
// Check login state
|
|
7
8
|
const bodyText = document.body?.innerText || ''
|
|
@@ -9,15 +10,40 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
9
10
|
const securityBlock = /安全限制|访问链接异常/.test(bodyText)
|
|
10
11
|
|| /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
|
|
11
12
|
|
|
12
|
-
// Scroll
|
|
13
|
+
// Scroll to trigger comment loading. Xiaohongshu loads comments in
|
|
14
|
+
// small async batches via IntersectionObserver, and depending on the
|
|
15
|
+
// page layout / viewport the actual scrollable ancestor can be
|
|
16
|
+
// .note-scroller, .container, or the document itself — so each round
|
|
17
|
+
// drives all of them plus scrollIntoView on the last loaded comment,
|
|
18
|
+
// which works regardless of which element actually owns the scrollbar.
|
|
19
|
+
// A single stalled round doesn't mean the list is exhausted — keep
|
|
20
|
+
// going until growth stalls for several consecutive rounds, we've
|
|
21
|
+
// loaded enough top-level comments to satisfy --limit, or we hit the
|
|
22
|
+
// hard round cap.
|
|
13
23
|
const scroller = document.querySelector('.note-scroller') || document.querySelector('.container')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const driveScroll = () => {
|
|
25
|
+
if (scroller) scroller.scrollTo(0, scroller.scrollHeight)
|
|
26
|
+
const comments = document.querySelectorAll('.parent-comment')
|
|
27
|
+
const last = comments[comments.length - 1]
|
|
28
|
+
if (last && typeof last.scrollIntoView === 'function') last.scrollIntoView({ block: 'end' })
|
|
29
|
+
if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
|
|
30
|
+
window.scrollTo(0, document.body.scrollHeight)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
{
|
|
34
|
+
let stall = 0
|
|
35
|
+
for (let i = 0; i < 60; i++) {
|
|
36
|
+
const beforeCount = document.querySelectorAll('.parent-comment').length
|
|
37
|
+
if (beforeCount >= targetCount) break
|
|
38
|
+
driveScroll()
|
|
39
|
+
await wait(1000 + Math.random() * 1200)
|
|
40
|
+
const afterCount = document.querySelectorAll('.parent-comment').length
|
|
41
|
+
if (afterCount <= beforeCount) {
|
|
42
|
+
stall++
|
|
43
|
+
if (stall >= 6) break
|
|
44
|
+
} else {
|
|
45
|
+
stall = 0
|
|
46
|
+
}
|
|
21
47
|
}
|
|
22
48
|
}
|
|
23
49
|
|
|
@@ -26,12 +52,27 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
26
52
|
const parseLikes = (el) => {
|
|
27
53
|
return parseLikeCountText(clean(el))
|
|
28
54
|
}
|
|
29
|
-
const HREF_SELECTOR = ${JSON.stringify(
|
|
55
|
+
const HREF_SELECTOR = ${JSON.stringify(L)}
|
|
30
56
|
const extractAuthorHref = (el) => {
|
|
31
57
|
if (!el) return ''
|
|
32
58
|
const anchor = el.querySelector(HREF_SELECTOR)
|
|
33
59
|
return anchor ? (anchor.getAttribute('href') || '') : ''
|
|
34
60
|
}
|
|
61
|
+
// Attached comment photos, excluding avatars, inline emoji, badges, and
|
|
62
|
+
// other UI images. Only images inside comment/reply media containers are
|
|
63
|
+
// projected as media evidence.
|
|
64
|
+
const extractImages = (el) => {
|
|
65
|
+
if (!el) return []
|
|
66
|
+
const urls = []
|
|
67
|
+
el.querySelectorAll('img').forEach(img => {
|
|
68
|
+
if (img.classList.contains('avatar-item')) return
|
|
69
|
+
if (img.closest('.content, .note-text')) return
|
|
70
|
+
if (!img.closest('.comment-pic, .reply-pic, .comment-image, .reply-image, .comment-img, .reply-img, [class*="comment-pic"], [class*="reply-pic"], [class*="comment-image"], [class*="reply-image"]')) return
|
|
71
|
+
const src = img.currentSrc || img.src || img.getAttribute('data-src') || ''
|
|
72
|
+
if (src && !urls.includes(src)) urls.push(src)
|
|
73
|
+
})
|
|
74
|
+
return urls
|
|
75
|
+
}
|
|
35
76
|
const expandReplyThreads = async (root) => {
|
|
36
77
|
if (!withReplies || !root) return
|
|
37
78
|
const clickedTexts = new Set()
|
|
@@ -65,9 +106,10 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
65
106
|
const text = clean(item.querySelector('.content, .note-text'))
|
|
66
107
|
const likes = parseLikes(item.querySelector('.count'))
|
|
67
108
|
const time = clean(item.querySelector('.date, .time'))
|
|
109
|
+
const images = extractImages(item)
|
|
68
110
|
|
|
69
111
|
if (!text) continue
|
|
70
|
-
results.push({ author, authorHrefRaw, text, likes, time, is_reply: false, reply_to: '' })
|
|
112
|
+
results.push({ author, authorHrefRaw, text, likes, time, is_reply: false, reply_to: '', images })
|
|
71
113
|
|
|
72
114
|
// Extract nested replies (楼中楼)
|
|
73
115
|
if (withReplies) {
|
|
@@ -78,12 +120,13 @@ import{cli as A,Strategy as J}from"@jackwener/opencli/registry";import{AuthRequi
|
|
|
78
120
|
const sText = clean(sub.querySelector('.content, .note-text'))
|
|
79
121
|
const sLikes = parseLikes(sub.querySelector('.count'))
|
|
80
122
|
const sTime = clean(sub.querySelector('.date, .time'))
|
|
123
|
+
const sImages = extractImages(sub)
|
|
81
124
|
if (!sText) return
|
|
82
|
-
results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author })
|
|
125
|
+
results.push({ author: sAuthor, authorHrefRaw: sAuthorHrefRaw, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author, images: sImages })
|
|
83
126
|
})
|
|
84
127
|
}
|
|
85
128
|
}
|
|
86
129
|
|
|
87
130
|
return { pageUrl: location.href, securityBlock, loginWall, results }
|
|
88
131
|
})()
|
|
89
|
-
`}export const command=
|
|
132
|
+
`}export const command=F({site:"xiaohongshu",name:"comments",access:"read",description:"获取小红书笔记评论(支持楼中楼子回复)",domain:"www.xiaohongshu.com",strategy:U.COOKIE,navigateBefore:!1,args:[{name:"note-id",required:!0,positional:!0,help:"Full Xiaohongshu note URL with xsec_token"},{name:"limit",type:"int",default:20,help:"Number of top-level comments (max 50)"},{name:"with-replies",type:"boolean",default:!1,help:"Include nested replies (楼中楼)"}],columns:["rank","author","userId","profileUrl","text","likes","time","is_reply","reply_to","images"],func:async(G,D)=>{const B=parseCommentLimit(D.limit),Q=Boolean(D["with-replies"]),K=String(D["note-id"]),M=y(K);await G.goto(P(K,{commandName:"xiaohongshu comments"}));await G.wait({time:2+Math.random()*3});const Y=await G.evaluate(buildCommentsExtractJs(Q,B));if(!Y||typeof Y!=="object")throw new _("xiaohongshu/comments","Unexpected evaluate response");if(Y.securityBlock)throw new T("SECURITY_BLOCK","Xiaohongshu security block: the note detail page was blocked by risk control.",/^https?:\/\//.test(K)?"The page may be temporarily restricted. Try again later or from a different session.":"Try using a full URL from search results (with xsec_token) instead of a bare note ID.");if(Y.loginWall)throw new j("www.xiaohongshu.com","Note comments require login");const A=normalizeCommentRows(Y.results,"xiaohongshu/comments"),J=(V,q)=>({rank:q+1,author:V.author,userId:V.authorHrefRaw?parseXhsProfileHref(V.authorHrefRaw):"",profileUrl:V.authorHrefRaw?buildXhsProfileUrl(V.authorHrefRaw):"",text:V.text,likes:V.likes,time:V.time,is_reply:V.is_reply,reply_to:V.reply_to,images:V.images??[]});if(Q){const V=[];let q=0;for(const W of A){if(!W.is_reply)q++;if(q>B)break;V.push(W)}return V.map(J)}return A.slice(0,B).map(J)}});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function E(q,D){return q+Math.random()*(D-q)}function V(q,D=0.4,B=0.02){const A=q*D;return Math.max(B,q-A+Math.random()*A*2)}export async function humanWait(q,D,B=0.4){try{await q.wait({time:V(D,B)})}catch{}}export async function humanType(q,D){const B=String(D);if(!B)return;if(typeof q.insertText!=="function"){try{await q.insertText?.(B)}catch{}return}let A=0;while(A<B.length){const H=Math.max(1,Math.round(E(1,4))),I=B.slice(A,A+H);A+=I.length;try{await q.insertText(I)}catch{try{await q.insertText(B.slice(A-I.length))}catch{}return}const K=Math.random()<0.12?E(0.35,0.8):E(0.04,0.18);try{await q.wait({time:K})}catch{}}}function Y(q){if(!q.__xhsCursor)q.__xhsCursor={x:Math.round(E(80,520)),y:Math.round(E(120,420))};return q.__xhsCursor}function Z(q,D,B){const A=D.x-q.x,H=D.y-q.y,I=Math.hypot(A,H)||1,K=-H/I,N=A/I,J=E(0.05,0.22)*I*(Math.random()<0.5?1:-1),Q={x:q.x+A*E(0.2,0.4)+K*J,y:q.y+H*E(0.2,0.4)+N*J},R={x:q.x+A*E(0.6,0.8)+K*J*E(0.3,0.7),y:q.y+H*E(0.6,0.8)+N*J*E(0.3,0.7)},U=[];for(let O=1;O<=B;O++){const F=O/B,G=1-F,W=G*G*G*q.x+3*G*G*F*Q.x+3*G*F*F*R.x+F*F*F*D.x,X=G*G*G*q.y+3*G*G*F*Q.y+3*G*F*F*R.y+F*F*F*D.y;U.push({x:Math.round(W),y:Math.round(X)})}return U}export async function humanMoveTo(q,D,B){if(typeof q.cdp!=="function")return;const A={x:Math.round(D+E(-3,3)),y:Math.round(B+E(-3,3))},H=Y(q),I=Math.hypot(A.x-H.x,A.y-H.y),K=Math.max(6,Math.min(18,Math.round(I/30))),N=Z(H,A,K);for(const J of N){try{await q.cdp("Input.dispatchMouseEvent",{type:"mouseMoved",x:J.x,y:J.y})}catch{return}try{await q.wait({time:V(0.02,0.6,0.008)})}catch{}}q.__xhsCursor=A}export async function elementViewportCenter(q,D){try{const B=await q.evaluate(`
|
|
2
|
+
(sels => {
|
|
3
|
+
const __opencli_xhs_el_center = true;
|
|
4
|
+
for (const sel of sels) {
|
|
5
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
6
|
+
if (!el || el.offsetParent === null) continue;
|
|
7
|
+
const r = el.getBoundingClientRect();
|
|
8
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
9
|
+
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
})(${JSON.stringify(D)})
|
|
14
|
+
`),A=B&&typeof B==="object"&&"data"in B?B.data:B;if(A&&typeof A.x==="number"&&typeof A.y==="number")return A}catch{}return null}
|