@web-auto/camo 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/package.json +2 -1
- package/scripts/check-file-size.mjs +80 -0
- package/scripts/file-size-policy.json +8 -0
- package/src/autoscript/action-providers/index.mjs +9 -0
- package/src/autoscript/action-providers/xhs/comments.mjs +412 -0
- package/src/autoscript/action-providers/xhs/common.mjs +77 -0
- package/src/autoscript/action-providers/xhs/detail.mjs +181 -0
- package/src/autoscript/action-providers/xhs/interaction.mjs +466 -0
- package/src/autoscript/action-providers/xhs/like-rules.mjs +57 -0
- package/src/autoscript/action-providers/xhs/persistence.mjs +167 -0
- package/src/autoscript/action-providers/xhs/search.mjs +174 -0
- package/src/autoscript/action-providers/xhs.mjs +133 -0
- package/src/autoscript/impact-engine.mjs +78 -0
- package/src/autoscript/runtime.mjs +1015 -0
- package/src/autoscript/schema.mjs +370 -0
- package/src/autoscript/xhs-unified-template.mjs +931 -0
- package/src/cli.mjs +185 -79
- package/src/commands/autoscript.mjs +1100 -0
- package/src/commands/browser.mjs +20 -4
- package/src/commands/container.mjs +298 -75
- package/src/commands/events.mjs +152 -0
- package/src/commands/lifecycle.mjs +17 -3
- package/src/commands/window.mjs +32 -1
- package/src/container/change-notifier.mjs +165 -24
- package/src/container/element-filter.mjs +51 -5
- package/src/container/runtime-core/checkpoint.mjs +195 -0
- package/src/container/runtime-core/index.mjs +21 -0
- package/src/container/runtime-core/operations/index.mjs +351 -0
- package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
- package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
- package/src/container/runtime-core/operations/viewport.mjs +143 -0
- package/src/container/runtime-core/subscription.mjs +87 -0
- package/src/container/runtime-core/utils.mjs +94 -0
- package/src/container/runtime-core/validation.mjs +127 -0
- package/src/container/runtime-core.mjs +1 -0
- package/src/container/subscription-registry.mjs +459 -0
- package/src/core/actions.mjs +573 -0
- package/src/core/browser.mjs +270 -0
- package/src/core/index.mjs +53 -0
- package/src/core/utils.mjs +87 -0
- package/src/events/daemon-entry.mjs +33 -0
- package/src/events/daemon.mjs +80 -0
- package/src/events/progress-log.mjs +109 -0
- package/src/events/ws-server.mjs +239 -0
- package/src/lib/client.mjs +8 -5
- package/src/lifecycle/session-registry.mjs +8 -4
- package/src/lifecycle/session-watchdog.mjs +220 -0
- package/src/utils/browser-service.mjs +232 -9
- package/src/utils/help.mjs +26 -3
package/README.md
CHANGED
|
@@ -146,6 +146,41 @@ camo mouse wheel [profileId] [--deltax <px>] [--deltay <px>]
|
|
|
146
146
|
camo system display # Show display metrics
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Container Subscription
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
camo container init [--source <container-library-dir>] [--force]
|
|
153
|
+
camo container sets [--site <siteKey>]
|
|
154
|
+
camo container register [profileId] <setId...> [--append]
|
|
155
|
+
camo container targets [profileId]
|
|
156
|
+
camo container filter [profileId] <selector...>
|
|
157
|
+
camo container watch [profileId] [--selector <css>] [--throttle <ms>]
|
|
158
|
+
camo container list [profileId]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Autoscript
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
camo autoscript scaffold xhs-unified [--output <file>] [options]
|
|
165
|
+
camo autoscript validate <file>
|
|
166
|
+
camo autoscript explain <file>
|
|
167
|
+
camo autoscript snapshot <jsonl-file> [--out <snapshot-file>]
|
|
168
|
+
camo autoscript replay <jsonl-file> [--summary-file <path>]
|
|
169
|
+
camo autoscript run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]
|
|
170
|
+
camo autoscript resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]
|
|
171
|
+
camo autoscript mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Progress Events (WS)
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
camo events serve [--host 127.0.0.1] [--port 7788]
|
|
178
|
+
camo events tail [--profile <id>] [--run-id <id>] [--events e1,e2] [--replay 50]
|
|
179
|
+
camo events recent [--limit 50]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
By default, non-`events` commands auto-start the progress daemon (`/events`) in background.
|
|
183
|
+
|
|
149
184
|
## Fingerprint Options
|
|
150
185
|
|
|
151
186
|
### OS Options
|
|
@@ -180,11 +215,113 @@ camo system display # Show display metrics
|
|
|
180
215
|
- Session registry: `~/.webauto/sessions/`
|
|
181
216
|
- Lock files: `~/.webauto/locks/`
|
|
182
217
|
- GeoIP database: `~/.webauto/geoip/GeoLite2-City.mmdb`
|
|
218
|
+
- User container root: `~/.webauto/container-lib/`
|
|
219
|
+
- Subscription root: `~/.webauto/container-subscriptions/`
|
|
220
|
+
|
|
221
|
+
### Subscription-driven Watch
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# 1) Migrate container-library into subscription sets
|
|
225
|
+
camo container init --source /Users/fanzhang/Documents/github/webauto/container-library
|
|
226
|
+
|
|
227
|
+
# 2) Register sets to a profile
|
|
228
|
+
camo container register xiaohongshu-batch-1 xiaohongshu_home xiaohongshu_home.search_input
|
|
229
|
+
|
|
230
|
+
# 3) Start watch using registered selectors (no --selector needed)
|
|
231
|
+
camo container watch xiaohongshu-batch-1 --throttle 500
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Autoscript Mode (Subscription + Operations)
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# Generate xiaohongshu unified-harvest migration script from webauto phase-unified-harvest
|
|
238
|
+
camo autoscript scaffold xhs-unified \
|
|
239
|
+
--output ./autoscripts/xiaohongshu/unified-harvest.autoscript.json \
|
|
240
|
+
--profile xiaohongshu-batch-1 \
|
|
241
|
+
--keyword "手机膜" \
|
|
242
|
+
--tab-count 4 \
|
|
243
|
+
--note-interval 900 \
|
|
244
|
+
--do-comments \
|
|
245
|
+
--do-likes \
|
|
246
|
+
--max-notes 30
|
|
247
|
+
|
|
248
|
+
# Validate + explain + run
|
|
249
|
+
camo autoscript validate ./autoscripts/xiaohongshu/unified-harvest.autoscript.json
|
|
250
|
+
camo autoscript explain ./autoscripts/xiaohongshu/unified-harvest.autoscript.json
|
|
251
|
+
camo autoscript run ./autoscripts/xiaohongshu/unified-harvest.autoscript.json \
|
|
252
|
+
--profile xiaohongshu-batch-1 \
|
|
253
|
+
--jsonl-file ./runs/xhs-unified/run.jsonl \
|
|
254
|
+
--summary-file ./runs/xhs-unified/run.summary.json
|
|
255
|
+
|
|
256
|
+
# Build snapshot + replay summary from existing JSONL
|
|
257
|
+
camo autoscript snapshot ./runs/xhs-unified/run.jsonl \
|
|
258
|
+
--out ./runs/xhs-unified/run.snapshot.json
|
|
259
|
+
camo autoscript replay ./runs/xhs-unified/run.jsonl \
|
|
260
|
+
--summary-file ./runs/xhs-unified/replay.summary.json
|
|
261
|
+
|
|
262
|
+
# Resume from a snapshot (optionally force rerun from a node)
|
|
263
|
+
camo autoscript resume ./autoscripts/xiaohongshu/unified-harvest.autoscript.json \
|
|
264
|
+
--snapshot ./runs/xhs-unified/run.snapshot.json \
|
|
265
|
+
--from-node comments_harvest \
|
|
266
|
+
--profile xiaohongshu-batch-1
|
|
267
|
+
|
|
268
|
+
# Mock replay mode for deterministic local debugging
|
|
269
|
+
camo autoscript mock-run ./autoscripts/xiaohongshu/unified-harvest.autoscript.json \
|
|
270
|
+
--fixture ./autoscripts/xiaohongshu/fixtures/mock-run.json \
|
|
271
|
+
--summary-file ./runs/xhs-unified/mock.summary.json
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The xhs-unified scaffold includes anti-risk defaults:
|
|
275
|
+
- operation pacing (`operationMinIntervalMs`, `eventCooldownMs`, `jitterMs`)
|
|
276
|
+
- navigation/tab switch cooldown (`navigationMinIntervalMs`)
|
|
277
|
+
- per-operation timeout budget (`timeoutMs`)
|
|
278
|
+
- multi-tab rotation (`ensure_tab_pool`, `tab_pool_switch_next`)
|
|
279
|
+
|
|
280
|
+
Example script:
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{
|
|
284
|
+
"name": "xhs-login-flow",
|
|
285
|
+
"profileId": "xiaohongshu-batch-1",
|
|
286
|
+
"throttle": 500,
|
|
287
|
+
"subscriptions": [
|
|
288
|
+
{ "id": "login_input", "selector": "#login-input" },
|
|
289
|
+
{ "id": "submit_btn", "selector": "button.submit" }
|
|
290
|
+
],
|
|
291
|
+
"operations": [
|
|
292
|
+
{
|
|
293
|
+
"id": "fill_login",
|
|
294
|
+
"action": "type",
|
|
295
|
+
"selector": "#login-input",
|
|
296
|
+
"text": "demo@example.com",
|
|
297
|
+
"trigger": "login_input.appear"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"id": "click_submit",
|
|
301
|
+
"action": "click",
|
|
302
|
+
"selector": "button.submit",
|
|
303
|
+
"trigger": { "subscription": "submit_btn", "event": "exist" },
|
|
304
|
+
"conditions": [
|
|
305
|
+
{ "type": "operation_done", "operationId": "fill_login" },
|
|
306
|
+
{ "type": "subscription_exist", "subscriptionId": "submit_btn" }
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Condition types:
|
|
314
|
+
- `operation_done`: previous operation completed
|
|
315
|
+
- `subscription_exist`: subscribed element currently exists
|
|
316
|
+
- `subscription_appear`: subscribed element has appeared at least once
|
|
183
317
|
|
|
184
318
|
### Environment Variables
|
|
185
319
|
|
|
186
320
|
- `WEBAUTO_BROWSER_URL` - Browser service URL (default: `http://127.0.0.1:7704`)
|
|
187
321
|
- `WEBAUTO_REPO_ROOT` - WebAuto repository root (optional)
|
|
322
|
+
- `WEBAUTO_CONTAINER_ROOT` - User container root override (default: `~/.webauto/container-lib`)
|
|
323
|
+
- `CAMO_PROGRESS_EVENTS_FILE` - Optional progress event JSONL path override
|
|
324
|
+
- `CAMO_PROGRESS_WS_HOST` / `CAMO_PROGRESS_WS_PORT` - Progress websocket daemon bind address (default: `127.0.0.1:7788`)
|
|
188
325
|
|
|
189
326
|
## Session Persistence
|
|
190
327
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@web-auto/camo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Camoufox Browser CLI - Cross-platform browser automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "node scripts/build.mjs",
|
|
18
|
+
"check:file-size": "node scripts/check-file-size.mjs",
|
|
18
19
|
"test": "node --test 'tests/**/*.test.mjs'",
|
|
19
20
|
"test:coverage": "c8 --reporter=text --reporter=lcov node --test 'tests/**/*.test.mjs'",
|
|
20
21
|
"version:bump": "node scripts/bump-version.mjs",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ROOT = process.cwd();
|
|
6
|
+
const TARGET_DIR = path.join(ROOT, 'src');
|
|
7
|
+
const MAX_LINES = Number(process.env.FILE_MAX_LINES || 500);
|
|
8
|
+
const POLICY_FILE = path.join(ROOT, 'scripts', 'file-size-policy.json');
|
|
9
|
+
const EXTS = new Set(['.mjs', '.js', '.ts', '.tsx']);
|
|
10
|
+
|
|
11
|
+
async function walk(dir) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const fullPath = path.join(dir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
18
|
+
out.push(...await walk(fullPath));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (!entry.isFile()) continue;
|
|
22
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
23
|
+
if (!EXTS.has(ext)) continue;
|
|
24
|
+
out.push(fullPath);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function countLines(content) {
|
|
30
|
+
if (!content) return 0;
|
|
31
|
+
return content.split(/\r?\n/).length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relative(p) {
|
|
35
|
+
return path.relative(ROOT, p).replaceAll(path.sep, '/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
let policy = { defaultMaxLines: MAX_LINES, overrides: {} };
|
|
40
|
+
try {
|
|
41
|
+
const raw = await fs.readFile(POLICY_FILE, 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
policy = {
|
|
44
|
+
defaultMaxLines: Number(parsed?.defaultMaxLines || MAX_LINES),
|
|
45
|
+
overrides: parsed?.overrides && typeof parsed.overrides === 'object' ? parsed.overrides : {},
|
|
46
|
+
};
|
|
47
|
+
} catch {}
|
|
48
|
+
|
|
49
|
+
const files = await walk(TARGET_DIR);
|
|
50
|
+
const violations = [];
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const text = await fs.readFile(file, 'utf8');
|
|
54
|
+
const lines = countLines(text);
|
|
55
|
+
const rel = relative(file);
|
|
56
|
+
const overrideLimit = Number(policy.overrides?.[rel]);
|
|
57
|
+
const limit = Number.isFinite(overrideLimit) && overrideLimit > 0
|
|
58
|
+
? overrideLimit
|
|
59
|
+
: policy.defaultMaxLines;
|
|
60
|
+
if (lines > limit) {
|
|
61
|
+
violations.push({ file: rel, lines, limit });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (violations.length === 0) {
|
|
66
|
+
console.log(`file-size-check: OK (${files.length} files, default max ${policy.defaultMaxLines} lines)`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.error(`file-size-check: ${violations.length} file(s) exceed configured limits`);
|
|
71
|
+
for (const item of violations) {
|
|
72
|
+
console.error(` - ${item.file}: ${item.lines} > ${item.limit}`);
|
|
73
|
+
}
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(`file-size-check: failed - ${err?.message || err}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { executeXhsAutoscriptOperation, isXhsAutoscriptAction } from './xhs.mjs';
|
|
2
|
+
|
|
3
|
+
export async function executeAutoscriptAction({ profileId, action, params = {} }) {
|
|
4
|
+
if (isXhsAutoscriptAction(action)) {
|
|
5
|
+
return executeXhsAutoscriptOperation({ profileId, action, params });
|
|
6
|
+
}
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
export function buildCommentsHarvestScript(params = {}) {
|
|
2
|
+
const maxRounds = Math.max(1, Number(params.maxRounds ?? params.maxScrollRounds ?? 14) || 14);
|
|
3
|
+
const scrollStep = Math.max(120, Number(params.scrollStep ?? 420) || 420);
|
|
4
|
+
const settleMs = Math.max(80, Number(params.settleMs ?? 180) || 180);
|
|
5
|
+
const stallRounds = Math.max(1, Number(params.stallRounds ?? 2) || 2);
|
|
6
|
+
const requireBottom = params.requireBottom !== false;
|
|
7
|
+
const includeComments = params.includeComments !== false;
|
|
8
|
+
const commentsLimit = Math.max(0, Number(params.commentsLimit ?? 0) || 0);
|
|
9
|
+
const recoveryStuckRounds = Math.max(1, Number(params.recoveryStuckRounds ?? 2) || 2);
|
|
10
|
+
const recoveryUpRounds = Math.max(1, Number(params.recoveryUpRounds ?? 2) || 2);
|
|
11
|
+
const recoveryDownRounds = Math.max(1, Number(params.recoveryDownRounds ?? 3) || 3);
|
|
12
|
+
const maxRecoveries = Math.max(0, Number(params.maxRecoveries ?? 3) || 3);
|
|
13
|
+
const recoveryUpStep = Math.max(80, Number(params.recoveryUpStep ?? Math.floor(scrollStep * 0.75)) || Math.floor(scrollStep * 0.75));
|
|
14
|
+
const recoveryDownStep = Math.max(120, Number(params.recoveryDownStep ?? Math.floor(scrollStep * 1.3)) || Math.floor(scrollStep * 1.3));
|
|
15
|
+
const recoveryNoProgressRounds = Math.max(1, Number(params.recoveryNoProgressRounds ?? 3) || 3);
|
|
16
|
+
const progressDiffThreshold = Math.max(2, Number(
|
|
17
|
+
params.progressDiffThreshold ?? Math.max(12, Math.floor(scrollStep * 0.08)),
|
|
18
|
+
) || Math.max(12, Math.floor(scrollStep * 0.08)));
|
|
19
|
+
const recoveryDownBoostPerAttempt = Math.max(0, Number(params.recoveryDownBoostPerAttempt ?? 1) || 1);
|
|
20
|
+
const maxRecoveryDownBoost = Math.max(0, Number(params.maxRecoveryDownBoost ?? 2) || 2);
|
|
21
|
+
const adaptiveMaxRounds = params.adaptiveMaxRounds !== false;
|
|
22
|
+
const adaptiveExpectedPerRound = Math.max(1, Number(params.adaptiveExpectedPerRound ?? 6) || 6);
|
|
23
|
+
const adaptiveBufferRounds = Math.max(0, Number(params.adaptiveBufferRounds ?? 22) || 22);
|
|
24
|
+
const adaptiveMinBoostRounds = Math.max(0, Number(params.adaptiveMinBoostRounds ?? 36) || 36);
|
|
25
|
+
const adaptiveMaxRoundsCap = Math.max(maxRounds, Number(params.adaptiveMaxRoundsCap ?? 320) || 320);
|
|
26
|
+
|
|
27
|
+
return `(async () => {
|
|
28
|
+
const state = window.__camoXhsState || (window.__camoXhsState = {});
|
|
29
|
+
const metricsState = state.metrics && typeof state.metrics === 'object' ? state.metrics : {};
|
|
30
|
+
state.metrics = metricsState;
|
|
31
|
+
metricsState.searchCount = Number(metricsState.searchCount || 0);
|
|
32
|
+
const detailSelectors = [
|
|
33
|
+
'.note-detail-mask',
|
|
34
|
+
'.note-detail-page',
|
|
35
|
+
'.note-detail-dialog',
|
|
36
|
+
'.note-detail-mask .detail-container',
|
|
37
|
+
'.note-detail-mask .media-container',
|
|
38
|
+
'.note-detail-mask .note-scroller',
|
|
39
|
+
'.note-detail-mask .note-content',
|
|
40
|
+
'.note-detail-mask .interaction-container',
|
|
41
|
+
'.note-detail-mask .comments-container',
|
|
42
|
+
];
|
|
43
|
+
const isVisible = (node) => {
|
|
44
|
+
if (!node || !(node instanceof HTMLElement)) return false;
|
|
45
|
+
const style = window.getComputedStyle(node);
|
|
46
|
+
if (!style) return false;
|
|
47
|
+
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) return false;
|
|
48
|
+
const rect = node.getBoundingClientRect();
|
|
49
|
+
return rect.width > 1 && rect.height > 1;
|
|
50
|
+
};
|
|
51
|
+
const isDetailVisible = () => detailSelectors.some((selector) => isVisible(document.querySelector(selector)));
|
|
52
|
+
const parseCountToken = (raw) => {
|
|
53
|
+
const token = String(raw || '').trim();
|
|
54
|
+
const matched = token.match(/^([0-9]+(?:\\.[0-9]+)?)(万|w|W)?$/);
|
|
55
|
+
if (!matched) return null;
|
|
56
|
+
const base = Number(matched[1]);
|
|
57
|
+
if (!Number.isFinite(base)) return null;
|
|
58
|
+
if (!matched[2]) return Math.round(base);
|
|
59
|
+
return Math.round(base * 10000);
|
|
60
|
+
};
|
|
61
|
+
const readExpectedCommentsCount = () => {
|
|
62
|
+
const scopeSelectors = [
|
|
63
|
+
'.note-detail-mask .interaction-container',
|
|
64
|
+
'.note-detail-mask .comments-container',
|
|
65
|
+
'.note-detail-page .interaction-container',
|
|
66
|
+
'.note-detail-page .comments-container',
|
|
67
|
+
'.note-detail-mask',
|
|
68
|
+
'.note-detail-page',
|
|
69
|
+
];
|
|
70
|
+
const patterns = [
|
|
71
|
+
/([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条?评论/,
|
|
72
|
+
/评论\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)/,
|
|
73
|
+
/共\\s*([0-9]+(?:\\.[0-9]+)?(?:万|w|W)?)\\s*条/,
|
|
74
|
+
];
|
|
75
|
+
for (const selector of scopeSelectors) {
|
|
76
|
+
const root = document.querySelector(selector);
|
|
77
|
+
if (!root) continue;
|
|
78
|
+
const text = String(root.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
79
|
+
if (!text) continue;
|
|
80
|
+
for (const re of patterns) {
|
|
81
|
+
const matched = text.match(re);
|
|
82
|
+
if (!matched || !matched[1]) continue;
|
|
83
|
+
const parsed = parseCountToken(matched[1]);
|
|
84
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const scroller = document.querySelector('.note-scroller')
|
|
91
|
+
|| document.querySelector('.comments-el')
|
|
92
|
+
|| document.querySelector('.comments-container')
|
|
93
|
+
|| document.scrollingElement
|
|
94
|
+
|| document.documentElement;
|
|
95
|
+
const readMetrics = () => {
|
|
96
|
+
const target = scroller || document.documentElement;
|
|
97
|
+
return {
|
|
98
|
+
scrollTop: Number(target?.scrollTop || 0),
|
|
99
|
+
scrollHeight: Number(target?.scrollHeight || 0),
|
|
100
|
+
clientHeight: Number(target?.clientHeight || window.innerHeight || 0),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const commentMap = new Map();
|
|
104
|
+
const collect = (round) => {
|
|
105
|
+
const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
|
|
106
|
+
for (const item of nodes) {
|
|
107
|
+
const textNode = item.querySelector('.content, .comment-content, p');
|
|
108
|
+
const authorNode = item.querySelector('.name, .author, .user-name, [class*="author"], [class*="name"]');
|
|
109
|
+
const text = String((textNode && textNode.textContent) || '').trim();
|
|
110
|
+
const author = String((authorNode && authorNode.textContent) || '').trim();
|
|
111
|
+
if (!text) continue;
|
|
112
|
+
const key = author + '::' + text;
|
|
113
|
+
if (commentMap.has(key)) continue;
|
|
114
|
+
const likeNode = item.querySelector('.like-wrapper, .comment-like, [class*="like"]');
|
|
115
|
+
commentMap.set(key, {
|
|
116
|
+
author,
|
|
117
|
+
text,
|
|
118
|
+
liked: Boolean(likeNode && /like-active/.test(String(likeNode.className || ''))),
|
|
119
|
+
firstSeenRound: round,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const configuredMaxRounds = Number(${maxRounds});
|
|
125
|
+
const scrollStep = Number(${scrollStep});
|
|
126
|
+
const settleMs = Number(${settleMs});
|
|
127
|
+
const stallRounds = Number(${stallRounds});
|
|
128
|
+
const requireBottom = ${requireBottom ? 'true' : 'false'};
|
|
129
|
+
const includeComments = ${includeComments ? 'true' : 'false'};
|
|
130
|
+
const commentsLimit = Number(${commentsLimit});
|
|
131
|
+
const recoveryStuckRounds = Number(${recoveryStuckRounds});
|
|
132
|
+
const recoveryUpRounds = Number(${recoveryUpRounds});
|
|
133
|
+
const recoveryDownRounds = Number(${recoveryDownRounds});
|
|
134
|
+
const maxRecoveries = Number(${maxRecoveries});
|
|
135
|
+
const recoveryUpStep = Number(${recoveryUpStep});
|
|
136
|
+
const recoveryDownStep = Number(${recoveryDownStep});
|
|
137
|
+
const recoveryNoProgressRounds = Number(${recoveryNoProgressRounds});
|
|
138
|
+
const progressDiffThreshold = Number(${progressDiffThreshold});
|
|
139
|
+
const recoveryDownBoostPerAttempt = Number(${recoveryDownBoostPerAttempt});
|
|
140
|
+
const maxRecoveryDownBoost = Number(${maxRecoveryDownBoost});
|
|
141
|
+
const adaptiveMaxRounds = ${adaptiveMaxRounds ? 'true' : 'false'};
|
|
142
|
+
const adaptiveExpectedPerRound = Number(${adaptiveExpectedPerRound});
|
|
143
|
+
const adaptiveBufferRounds = Number(${adaptiveBufferRounds});
|
|
144
|
+
const adaptiveMinBoostRounds = Number(${adaptiveMinBoostRounds});
|
|
145
|
+
const adaptiveMaxRoundsCap = Number(${adaptiveMaxRoundsCap});
|
|
146
|
+
let maxRounds = configuredMaxRounds;
|
|
147
|
+
let maxRoundsSource = 'configured';
|
|
148
|
+
let budgetExpectedCommentsCount = null;
|
|
149
|
+
const applyAdaptiveRounds = (expectedCommentsCount) => {
|
|
150
|
+
const expected = Number(expectedCommentsCount);
|
|
151
|
+
if (!adaptiveMaxRounds || !Number.isFinite(expected) || expected <= 0) return false;
|
|
152
|
+
const estimatedRounds = Math.ceil(expected / adaptiveExpectedPerRound) + adaptiveBufferRounds;
|
|
153
|
+
if (estimatedRounds <= configuredMaxRounds) return false;
|
|
154
|
+
const boostedRounds = Math.max(configuredMaxRounds + adaptiveMinBoostRounds, estimatedRounds);
|
|
155
|
+
const nextRounds = Math.max(configuredMaxRounds, Math.min(adaptiveMaxRoundsCap, boostedRounds));
|
|
156
|
+
maxRounds = nextRounds;
|
|
157
|
+
maxRoundsSource = 'adaptive_expected_comments';
|
|
158
|
+
budgetExpectedCommentsCount = Math.round(expected);
|
|
159
|
+
return true;
|
|
160
|
+
};
|
|
161
|
+
applyAdaptiveRounds(readExpectedCommentsCount());
|
|
162
|
+
let rounds = 0;
|
|
163
|
+
let reachedBottom = false;
|
|
164
|
+
let exitReason = 'max_rounds_reached';
|
|
165
|
+
let noProgressRounds = 0;
|
|
166
|
+
let noNewCommentsStreak = 0;
|
|
167
|
+
let stalledScrollRounds = 0;
|
|
168
|
+
let noEffectStreak = 0;
|
|
169
|
+
let recoveries = 0;
|
|
170
|
+
let bestRemainingDiff = Number.POSITIVE_INFINITY;
|
|
171
|
+
const recoveryReasonCounts = {
|
|
172
|
+
no_effect: 0,
|
|
173
|
+
no_new_comments: 0,
|
|
174
|
+
};
|
|
175
|
+
const performScroll = async (deltaY, waitMs = settleMs) => {
|
|
176
|
+
if (typeof scroller?.scrollBy === 'function') {
|
|
177
|
+
scroller.scrollBy({ top: deltaY, behavior: 'auto' });
|
|
178
|
+
} else {
|
|
179
|
+
window.scrollBy({ top: deltaY, behavior: 'auto' });
|
|
180
|
+
}
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for (let round = 1; round <= maxRounds; round += 1) {
|
|
185
|
+
rounds = round;
|
|
186
|
+
if (!isDetailVisible()) {
|
|
187
|
+
exitReason = 'detail_hidden';
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
const beforeCount = commentMap.size;
|
|
191
|
+
collect(round);
|
|
192
|
+
if ((budgetExpectedCommentsCount === null || budgetExpectedCommentsCount === undefined) && round <= 6) {
|
|
193
|
+
applyAdaptiveRounds(readExpectedCommentsCount());
|
|
194
|
+
}
|
|
195
|
+
const beforeMetrics = readMetrics();
|
|
196
|
+
const beforeDiff = beforeMetrics.scrollHeight - (beforeMetrics.scrollTop + beforeMetrics.clientHeight);
|
|
197
|
+
if (Number.isFinite(beforeDiff) && beforeDiff >= 0) {
|
|
198
|
+
bestRemainingDiff = Math.min(bestRemainingDiff, beforeDiff);
|
|
199
|
+
}
|
|
200
|
+
if (beforeDiff <= 6) {
|
|
201
|
+
reachedBottom = true;
|
|
202
|
+
exitReason = 'bottom_reached';
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const prevTop = beforeMetrics.scrollTop;
|
|
207
|
+
if (typeof scroller?.scrollBy === 'function') {
|
|
208
|
+
scroller.scrollBy({ top: scrollStep, behavior: 'auto' });
|
|
209
|
+
} else {
|
|
210
|
+
window.scrollBy({ top: scrollStep, behavior: 'auto' });
|
|
211
|
+
}
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
213
|
+
collect(round);
|
|
214
|
+
let afterMetrics = readMetrics();
|
|
215
|
+
let moved = Math.abs(afterMetrics.scrollTop - prevTop) > 1;
|
|
216
|
+
if (!moved && typeof window.scrollBy === 'function') {
|
|
217
|
+
window.scrollBy({ top: Math.max(120, Math.floor(scrollStep / 2)), behavior: 'auto' });
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
219
|
+
collect(round);
|
|
220
|
+
afterMetrics = readMetrics();
|
|
221
|
+
moved = Math.abs(afterMetrics.scrollTop - prevTop) > 1;
|
|
222
|
+
}
|
|
223
|
+
const increased = commentMap.size > beforeCount;
|
|
224
|
+
const afterDiff = afterMetrics.scrollHeight - (afterMetrics.scrollTop + afterMetrics.clientHeight);
|
|
225
|
+
const diffImproved = Number.isFinite(afterDiff) && Number.isFinite(beforeDiff)
|
|
226
|
+
? afterDiff <= (beforeDiff - progressDiffThreshold)
|
|
227
|
+
: false;
|
|
228
|
+
const bestImproved = Number.isFinite(afterDiff) && Number.isFinite(bestRemainingDiff)
|
|
229
|
+
? afterDiff <= (bestRemainingDiff - progressDiffThreshold)
|
|
230
|
+
: false;
|
|
231
|
+
if (Number.isFinite(afterDiff) && afterDiff >= 0) {
|
|
232
|
+
bestRemainingDiff = Math.min(bestRemainingDiff, afterDiff);
|
|
233
|
+
}
|
|
234
|
+
const progressedByScroll = diffImproved || bestImproved;
|
|
235
|
+
const progressed = increased || progressedByScroll;
|
|
236
|
+
if (!progressed) {
|
|
237
|
+
noProgressRounds += 1;
|
|
238
|
+
noNewCommentsStreak += 1;
|
|
239
|
+
} else {
|
|
240
|
+
noProgressRounds = 0;
|
|
241
|
+
noNewCommentsStreak = 0;
|
|
242
|
+
}
|
|
243
|
+
if (!moved) stalledScrollRounds += 1;
|
|
244
|
+
else stalledScrollRounds = 0;
|
|
245
|
+
if (!moved) noEffectStreak += 1;
|
|
246
|
+
else noEffectStreak = 0;
|
|
247
|
+
if (afterDiff <= 6) {
|
|
248
|
+
reachedBottom = true;
|
|
249
|
+
exitReason = 'bottom_reached';
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
let recoveryTrigger = null;
|
|
253
|
+
if (noNewCommentsStreak >= recoveryNoProgressRounds) recoveryTrigger = 'no_new_comments';
|
|
254
|
+
if (!recoveryTrigger && noEffectStreak >= recoveryStuckRounds) recoveryTrigger = 'no_effect';
|
|
255
|
+
if (recoveryTrigger) {
|
|
256
|
+
if (recoveries >= maxRecoveries) {
|
|
257
|
+
exitReason = recoveryTrigger === 'no_new_comments'
|
|
258
|
+
? 'no_new_comments_after_recovery_budget'
|
|
259
|
+
: 'scroll_stalled_after_recovery';
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
recoveries += 1;
|
|
263
|
+
recoveryReasonCounts[recoveryTrigger] += 1;
|
|
264
|
+
for (let i = 0; i < recoveryUpRounds; i += 1) {
|
|
265
|
+
await performScroll(-recoveryUpStep, settleMs + 120);
|
|
266
|
+
collect(round);
|
|
267
|
+
}
|
|
268
|
+
const downBoost = Math.min(maxRecoveryDownBoost, Math.max(0, recoveries - 1) * recoveryDownBoostPerAttempt);
|
|
269
|
+
const downRounds = recoveryDownRounds + downBoost;
|
|
270
|
+
for (let i = 0; i < downRounds; i += 1) {
|
|
271
|
+
await performScroll(recoveryDownStep, settleMs + 180);
|
|
272
|
+
collect(round);
|
|
273
|
+
}
|
|
274
|
+
const recoveredMetrics = readMetrics();
|
|
275
|
+
const recoveredDiff = recoveredMetrics.scrollHeight - (recoveredMetrics.scrollTop + recoveredMetrics.clientHeight);
|
|
276
|
+
const recoveredDiffImproved = Number.isFinite(recoveredDiff) && Number.isFinite(afterDiff)
|
|
277
|
+
? recoveredDiff <= (afterDiff - progressDiffThreshold)
|
|
278
|
+
: false;
|
|
279
|
+
const recoveredBestImproved = Number.isFinite(recoveredDiff) && Number.isFinite(bestRemainingDiff)
|
|
280
|
+
? recoveredDiff <= (bestRemainingDiff - progressDiffThreshold)
|
|
281
|
+
: false;
|
|
282
|
+
if (Number.isFinite(recoveredDiff) && recoveredDiff >= 0) {
|
|
283
|
+
bestRemainingDiff = Math.min(bestRemainingDiff, recoveredDiff);
|
|
284
|
+
}
|
|
285
|
+
if (recoveredDiff <= 6) {
|
|
286
|
+
reachedBottom = true;
|
|
287
|
+
exitReason = 'bottom_reached';
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
if (commentMap.size > beforeCount || recoveredDiffImproved || recoveredBestImproved) {
|
|
291
|
+
noProgressRounds = 0;
|
|
292
|
+
noNewCommentsStreak = 0;
|
|
293
|
+
}
|
|
294
|
+
noEffectStreak = 0;
|
|
295
|
+
stalledScrollRounds = 0;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (stalledScrollRounds >= stallRounds) {
|
|
299
|
+
exitReason = 'scroll_stalled';
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (noProgressRounds >= stallRounds) {
|
|
303
|
+
if (!requireBottom) {
|
|
304
|
+
exitReason = 'no_new_comments';
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (round === maxRounds) {
|
|
309
|
+
exitReason = 'max_rounds_reached';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const comments = Array.from(commentMap.values())
|
|
314
|
+
.sort((a, b) => Number(a.firstSeenRound || 0) - Number(b.firstSeenRound || 0))
|
|
315
|
+
.map((item, index) => ({
|
|
316
|
+
index,
|
|
317
|
+
author: item.author,
|
|
318
|
+
text: item.text,
|
|
319
|
+
liked: item.liked,
|
|
320
|
+
}));
|
|
321
|
+
const metrics = readMetrics();
|
|
322
|
+
const detectedExpectedCommentsCount = readExpectedCommentsCount();
|
|
323
|
+
const expectedCommentsCount = Number.isFinite(Number(detectedExpectedCommentsCount))
|
|
324
|
+
? Number(detectedExpectedCommentsCount)
|
|
325
|
+
: (Number.isFinite(Number(budgetExpectedCommentsCount)) ? Number(budgetExpectedCommentsCount) : null);
|
|
326
|
+
const commentCoverageRate = Number.isFinite(Number(expectedCommentsCount)) && Number(expectedCommentsCount) > 0
|
|
327
|
+
? Number(Math.min(1, comments.length / Number(expectedCommentsCount)).toFixed(4))
|
|
328
|
+
: null;
|
|
329
|
+
|
|
330
|
+
state.currentComments = comments;
|
|
331
|
+
state.commentsCollectedAt = new Date().toISOString();
|
|
332
|
+
state.lastCommentsHarvest = {
|
|
333
|
+
noteId: state.currentNoteId || null,
|
|
334
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
335
|
+
collected: comments.length,
|
|
336
|
+
expectedCommentsCount,
|
|
337
|
+
commentCoverageRate,
|
|
338
|
+
recoveries,
|
|
339
|
+
recoveryReasonCounts,
|
|
340
|
+
maxRecoveries,
|
|
341
|
+
recoveryNoProgressRounds,
|
|
342
|
+
reachedBottom,
|
|
343
|
+
exitReason,
|
|
344
|
+
rounds,
|
|
345
|
+
configuredMaxRounds,
|
|
346
|
+
maxRounds,
|
|
347
|
+
maxRoundsSource,
|
|
348
|
+
budgetExpectedCommentsCount,
|
|
349
|
+
scroll: metrics,
|
|
350
|
+
at: state.commentsCollectedAt,
|
|
351
|
+
};
|
|
352
|
+
const payload = {
|
|
353
|
+
noteId: state.currentNoteId || null,
|
|
354
|
+
searchCount: Number(metricsState.searchCount || 0),
|
|
355
|
+
collected: comments.length,
|
|
356
|
+
expectedCommentsCount,
|
|
357
|
+
commentCoverageRate,
|
|
358
|
+
recoveries,
|
|
359
|
+
recoveryReasonCounts,
|
|
360
|
+
maxRecoveries,
|
|
361
|
+
recoveryNoProgressRounds,
|
|
362
|
+
firstComment: comments[0] || null,
|
|
363
|
+
reachedBottom,
|
|
364
|
+
exitReason,
|
|
365
|
+
rounds,
|
|
366
|
+
configuredMaxRounds,
|
|
367
|
+
maxRounds,
|
|
368
|
+
maxRoundsSource,
|
|
369
|
+
budgetExpectedCommentsCount,
|
|
370
|
+
scroll: metrics,
|
|
371
|
+
};
|
|
372
|
+
if (includeComments) {
|
|
373
|
+
const bounded = commentsLimit > 0 ? comments.slice(0, commentsLimit) : comments;
|
|
374
|
+
payload.comments = bounded;
|
|
375
|
+
payload.commentsTruncated = commentsLimit > 0 && comments.length > commentsLimit;
|
|
376
|
+
}
|
|
377
|
+
return payload;
|
|
378
|
+
})()`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function buildCommentMatchScript(params = {}) {
|
|
382
|
+
const keywords = (Array.isArray(params.keywords || params.matchKeywords)
|
|
383
|
+
? (params.keywords || params.matchKeywords)
|
|
384
|
+
: String(params.keywords || params.matchKeywords || '').split(','))
|
|
385
|
+
.map((item) => String(item))
|
|
386
|
+
.filter(Boolean);
|
|
387
|
+
const mode = String(params.mode || params.matchMode || 'any');
|
|
388
|
+
const minHits = Math.max(1, Number(params.minHits ?? params.matchMinHits ?? 1) || 1);
|
|
389
|
+
|
|
390
|
+
return `(async () => {
|
|
391
|
+
const state = window.__camoXhsState || (window.__camoXhsState = {});
|
|
392
|
+
const rows = Array.isArray(state.currentComments) ? state.currentComments : [];
|
|
393
|
+
const keywords = ${JSON.stringify(keywords)};
|
|
394
|
+
const mode = ${JSON.stringify(mode)};
|
|
395
|
+
const minHits = Number(${minHits});
|
|
396
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
397
|
+
const tokens = keywords.map((item) => normalize(item)).filter(Boolean);
|
|
398
|
+
const matches = [];
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const text = normalize(row.text);
|
|
401
|
+
if (!text || tokens.length === 0) continue;
|
|
402
|
+
const hits = tokens.filter((token) => text.includes(token));
|
|
403
|
+
if (mode === 'all' && hits.length < tokens.length) continue;
|
|
404
|
+
if (mode === 'atLeast' && hits.length < Math.max(1, minHits)) continue;
|
|
405
|
+
if (mode !== 'all' && mode !== 'atLeast' && hits.length === 0) continue;
|
|
406
|
+
matches.push({ index: row.index, hits });
|
|
407
|
+
}
|
|
408
|
+
state.matchedComments = matches;
|
|
409
|
+
state.matchRule = { tokens, mode, minHits };
|
|
410
|
+
return { matchCount: matches.length, mode, minHits: Math.max(1, minHits) };
|
|
411
|
+
})()`;
|
|
412
|
+
}
|