harnery 0.41.0 → 0.42.0
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/dist/commander.js +3 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +9 -1
- package/dist/commands/artifacts.d.ts.map +1 -1
- package/dist/commands/artifacts.js +113 -12
- package/dist/commands/browse.d.ts +164 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +263 -51
- package/dist/commands/claude-desktop.d.ts.map +1 -1
- package/dist/commands/claude-desktop.js +52 -0
- package/dist/commands/disk.d.ts +6 -0
- package/dist/commands/disk.d.ts.map +1 -0
- package/dist/commands/disk.js +80 -0
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +48 -1
- package/dist/commands/files.d.ts +16 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +54 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +43 -9
- package/dist/commands/rm.d.ts +4 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +87 -0
- package/dist/commands/servers.d.ts +7 -0
- package/dist/commands/servers.d.ts.map +1 -0
- package/dist/commands/servers.js +394 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +82 -2
- package/dist/core/adapters/profiles.d.ts +9 -0
- package/dist/core/adapters/profiles.d.ts.map +1 -1
- package/dist/core/adapters/profiles.js +22 -1
- package/dist/core/adapters/types.d.ts +9 -0
- package/dist/core/adapters/types.d.ts.map +1 -1
- package/dist/core/agents/cli.js +13 -6
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +2 -0
- package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +60 -0
- package/dist/core/artifacts/delivery-card.js +9 -2
- package/dist/core/artifacts/index.d.ts +140 -4
- package/dist/core/artifacts/index.d.ts.map +1 -1
- package/dist/core/artifacts/index.js +522 -49
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +128 -3
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +2 -2
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +16 -0
- package/dist/core/events/v3/projection.js +9 -1
- package/dist/core/hooks/adapter/events.d.ts +17 -0
- package/dist/core/hooks/adapter/events.d.ts.map +1 -1
- package/dist/core/hooks/adapter/events.js +5 -0
- package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
- package/dist/core/hooks/adapter/wiring.d.ts +37 -7
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +62 -4
- package/dist/core/hooks/cli.js +45 -6
- package/dist/core/hooks/run-markers.d.ts +37 -0
- package/dist/core/hooks/run-markers.d.ts.map +1 -0
- package/dist/core/hooks/run-markers.js +109 -0
- package/dist/core/servers/index.d.ts +211 -0
- package/dist/core/servers/index.d.ts.map +1 -0
- package/dist/core/servers/index.js +560 -0
- package/dist/core/servers/net.d.ts +58 -0
- package/dist/core/servers/net.d.ts.map +1 -0
- package/dist/core/servers/net.js +270 -0
- package/dist/core/servers/tunnels.d.ts +14 -0
- package/dist/core/servers/tunnels.d.ts.map +1 -0
- package/dist/core/servers/tunnels.js +33 -0
- package/dist/core/storage/builtins.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +30 -1
- package/dist/core/workflow/sandbox-projection.d.ts +13 -2
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
- package/dist/core/workflow/sandbox-projection.js +23 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +6 -1
- package/dist/core/workflow/spawn-codex.d.ts +2 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +20 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +4 -1
- package/dist/core/workflow/types.d.ts +47 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/worker-access.d.ts +92 -0
- package/dist/core/workflow/worker-access.d.ts.map +1 -0
- package/dist/core/workflow/worker-access.js +450 -0
- package/dist/lib/browser/batch-steps.d.ts +18 -0
- package/dist/lib/browser/batch-steps.d.ts.map +1 -0
- package/dist/lib/browser/batch-steps.js +82 -0
- package/dist/lib/browser/collect/collector.d.ts +99 -0
- package/dist/lib/browser/collect/collector.d.ts.map +1 -0
- package/dist/lib/browser/collect/collector.js +202 -0
- package/dist/lib/browser/collect/fields.d.ts +31 -0
- package/dist/lib/browser/collect/fields.d.ts.map +1 -0
- package/dist/lib/browser/collect/fields.js +75 -0
- package/dist/lib/browser/collect/index.d.ts +6 -0
- package/dist/lib/browser/collect/index.d.ts.map +1 -0
- package/dist/lib/browser/collect/index.js +5 -0
- package/dist/lib/browser/collect/page-driver.d.ts +14 -0
- package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
- package/dist/lib/browser/collect/page-driver.js +306 -0
- package/dist/lib/browser/collect/presets.d.ts +52 -0
- package/dist/lib/browser/collect/presets.d.ts.map +1 -0
- package/dist/lib/browser/collect/presets.js +176 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
- package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
- package/dist/lib/browser/collect/scroll-plan.js +118 -0
- package/dist/lib/browser/index.d.ts +1 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.d.ts +2 -0
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +13 -0
- package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +1 -0
- package/dist/lib/browser/windows-chrome.d.ts +59 -0
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
- package/dist/lib/browser/windows-chrome.js +179 -0
- package/dist/lib/claude-desktop-share.d.ts +136 -0
- package/dist/lib/claude-desktop-share.d.ts.map +1 -0
- package/dist/lib/claude-desktop-share.js +394 -0
- package/dist/lib/disk-usage.d.ts +50 -0
- package/dist/lib/disk-usage.d.ts.map +1 -0
- package/dist/lib/disk-usage.js +303 -0
- package/dist/lib/docs-lint.d.ts +6 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +8 -3
- package/dist/lib/guarded-remove.d.ts +34 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -0
- package/dist/lib/guarded-remove.js +310 -0
- package/dist/lib/instructions/templates.js +8 -8
- package/dist/lib/tunnel/error-page.d.ts +1 -1
- package/dist/lib/tunnel/error-page.d.ts.map +1 -1
- package/dist/lib/tunnel/error-page.js +19 -8
- package/dist/lib/tunnel/gate.js +20 -1
- package/dist/lib/tunnel/path-scope.d.ts +22 -0
- package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
- package/dist/lib/tunnel/path-scope.js +62 -0
- package/dist/lib/tunnel/state.d.ts +5 -0
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +17 -3
- package/package.json +6 -1
- package/schemas/config.schema.json +91 -0
- package/src/commander.ts +19 -0
- package/src/commands/agents.ts +14 -0
- package/src/commands/artifacts.ts +171 -19
- package/src/commands/browse.ts +400 -57
- package/src/commands/claude-desktop.ts +61 -0
- package/src/commands/disk.ts +114 -0
- package/src/commands/doctor.ts +52 -1
- package/src/commands/files.ts +93 -2
- package/src/commands/init.ts +55 -5
- package/src/commands/rm.ts +115 -0
- package/src/commands/servers.ts +506 -0
- package/src/commands/tunnel.ts +102 -2
- package/src/core/adapters/profiles.ts +22 -1
- package/src/core/adapters/types.ts +10 -0
- package/src/core/agents/cli.ts +14 -6
- package/src/core/agents/render/session-context.ts +1 -0
- package/src/core/agents/state/live-coordination-view.ts +73 -0
- package/src/core/artifacts/delivery-card.ts +10 -2
- package/src/core/artifacts/index.ts +697 -44
- package/src/core/config.ts +183 -4
- package/src/core/diagnostics/bundle.ts +2 -1
- package/src/core/events/v3/producers/recorder.ts +16 -0
- package/src/core/events/v3/projection.ts +11 -1
- package/src/core/hooks/adapter/events.ts +19 -0
- package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
- package/src/core/hooks/adapter/wiring.ts +116 -8
- package/src/core/hooks/cli.ts +47 -6
- package/src/core/hooks/run-markers.ts +132 -0
- package/src/core/servers/index.ts +712 -0
- package/src/core/servers/net.ts +283 -0
- package/src/core/servers/tunnels.ts +39 -0
- package/src/core/storage/builtins.ts +1 -0
- package/src/core/workflow/engine.ts +38 -1
- package/src/core/workflow/sandbox-projection.ts +38 -2
- package/src/core/workflow/spawn-claude.ts +5 -1
- package/src/core/workflow/spawn-codex.ts +18 -1
- package/src/core/workflow/spawn-cursor.ts +5 -1
- package/src/core/workflow/spawn-opencode.ts +4 -1
- package/src/core/workflow/types.ts +62 -2
- package/src/core/workflow/worker-access.ts +525 -0
- package/src/lib/browser/batch-steps.ts +79 -0
- package/src/lib/browser/collect/collector.ts +306 -0
- package/src/lib/browser/collect/fields.ts +87 -0
- package/src/lib/browser/collect/index.ts +47 -0
- package/src/lib/browser/collect/page-driver.ts +327 -0
- package/src/lib/browser/collect/presets.ts +237 -0
- package/src/lib/browser/collect/scroll-plan.ts +179 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/page-review-judge.ts +16 -0
- package/src/lib/browser/qa-run-contracts.ts +11 -0
- package/src/lib/browser/qa-run.ts +1 -0
- package/src/lib/browser/windows-chrome.ts +227 -0
- package/src/lib/claude-desktop-share.ts +511 -0
- package/src/lib/disk-usage.ts +347 -0
- package/src/lib/docs-lint.ts +8 -3
- package/src/lib/guarded-remove.ts +376 -0
- package/src/lib/instructions/templates.ts +8 -8
- package/src/lib/tunnel/error-page.ts +25 -9
- package/src/lib/tunnel/gate.ts +22 -1
- package/src/lib/tunnel/path-scope.ts +73 -0
- package/src/lib/tunnel/state.ts +20 -3
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { planScrollStep, uniform, } from "./scroll-plan.js";
|
|
2
|
+
export const DEFAULT_COLLECT_LIMITS = {
|
|
3
|
+
maxItems: null,
|
|
4
|
+
maxScrolls: 60,
|
|
5
|
+
maxMs: 300_000,
|
|
6
|
+
idleSteps: 5,
|
|
7
|
+
expandPerStep: 0,
|
|
8
|
+
expandTotal: 50,
|
|
9
|
+
};
|
|
10
|
+
function isEmpty(value) {
|
|
11
|
+
return (value === null ||
|
|
12
|
+
value === undefined ||
|
|
13
|
+
value === "" ||
|
|
14
|
+
(Array.isArray(value) && value.length === 0));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Ordered, de-duplicated item store. The first sighting fixes an item's
|
|
18
|
+
* position and step; later sightings only fill in what was missing: empty
|
|
19
|
+
* fields, longer text (an expanded "show more"), and new array members.
|
|
20
|
+
*/
|
|
21
|
+
export class ItemStore {
|
|
22
|
+
byKey = new Map();
|
|
23
|
+
duplicates = 0;
|
|
24
|
+
get size() {
|
|
25
|
+
return this.byKey.size;
|
|
26
|
+
}
|
|
27
|
+
add(items, step, maxItems = null) {
|
|
28
|
+
let added = 0;
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
if (!item.key)
|
|
31
|
+
continue;
|
|
32
|
+
const existing = this.byKey.get(item.key);
|
|
33
|
+
if (existing) {
|
|
34
|
+
this.duplicates++;
|
|
35
|
+
mergeFields(existing, item.fields);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (maxItems !== null && this.byKey.size >= maxItems)
|
|
39
|
+
continue;
|
|
40
|
+
this.byKey.set(item.key, { key: item.key, firstSeenStep: step, ...item.fields });
|
|
41
|
+
added++;
|
|
42
|
+
}
|
|
43
|
+
return added;
|
|
44
|
+
}
|
|
45
|
+
items() {
|
|
46
|
+
return [...this.byKey.values()];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function mergeFields(target, fields) {
|
|
50
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
51
|
+
const current = target[name];
|
|
52
|
+
if (isEmpty(value))
|
|
53
|
+
continue;
|
|
54
|
+
if (isEmpty(current)) {
|
|
55
|
+
target[name] = value;
|
|
56
|
+
}
|
|
57
|
+
else if (typeof current === "string" && typeof value === "string") {
|
|
58
|
+
if (value.length > current.length)
|
|
59
|
+
target[name] = value;
|
|
60
|
+
}
|
|
61
|
+
else if (Array.isArray(current) && Array.isArray(value)) {
|
|
62
|
+
const merged = [...current];
|
|
63
|
+
for (const v of value)
|
|
64
|
+
if (!merged.includes(v))
|
|
65
|
+
merged.push(v);
|
|
66
|
+
target[name] = merged;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Scroll a feed like a reader and collect every item that passes through the
|
|
72
|
+
* DOM. Each step harvests right after scrolling and again after the pause, so
|
|
73
|
+
* a virtualized list cannot drop an item between two harvests.
|
|
74
|
+
*/
|
|
75
|
+
export async function runCollect(driver, options) {
|
|
76
|
+
const { limits, pacing, rng } = options;
|
|
77
|
+
const store = new ItemStore();
|
|
78
|
+
const started = driver.now();
|
|
79
|
+
const stats = {
|
|
80
|
+
steps: 0,
|
|
81
|
+
harvests: 0,
|
|
82
|
+
items: 0,
|
|
83
|
+
duplicates: 0,
|
|
84
|
+
expanded: 0,
|
|
85
|
+
readingPauses: 0,
|
|
86
|
+
scrollBacks: 0,
|
|
87
|
+
scrollFallbacks: 0,
|
|
88
|
+
elapsedMs: 0,
|
|
89
|
+
stopReason: "idle",
|
|
90
|
+
stopDetail: null,
|
|
91
|
+
};
|
|
92
|
+
const elapsed = () => driver.now() - started;
|
|
93
|
+
const interrupted = () => options.shouldStop?.() === true;
|
|
94
|
+
const full = () => limits.maxItems !== null && store.size >= limits.maxItems;
|
|
95
|
+
const harvest = async (step) => {
|
|
96
|
+
stats.harvests++;
|
|
97
|
+
return store.add(await driver.harvest(), step, limits.maxItems);
|
|
98
|
+
};
|
|
99
|
+
/** Sleep in slices so an interrupt or the time budget cuts a pause short. */
|
|
100
|
+
const pause = async (ms) => {
|
|
101
|
+
let remaining = Math.min(ms, Math.max(0, limits.maxMs - elapsed()));
|
|
102
|
+
while (remaining > 0 && !interrupted()) {
|
|
103
|
+
const slice = Math.min(remaining, 250);
|
|
104
|
+
await driver.sleep(slice);
|
|
105
|
+
remaining -= slice;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const expand = async (step) => {
|
|
109
|
+
if (limits.expandPerStep <= 0)
|
|
110
|
+
return 0;
|
|
111
|
+
const budget = Math.min(limits.expandPerStep, limits.expandTotal - stats.expanded);
|
|
112
|
+
if (budget <= 0)
|
|
113
|
+
return 0;
|
|
114
|
+
const clicked = await driver.expand(budget);
|
|
115
|
+
stats.expanded += clicked;
|
|
116
|
+
return clicked > 0 ? harvest(step) : 0;
|
|
117
|
+
};
|
|
118
|
+
const stop = (reason, detail = null) => {
|
|
119
|
+
stats.stopReason = reason;
|
|
120
|
+
stats.stopDetail = detail;
|
|
121
|
+
};
|
|
122
|
+
await harvest(0);
|
|
123
|
+
await expand(0);
|
|
124
|
+
let idle = 0;
|
|
125
|
+
let before = await driver.metrics();
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (interrupted()) {
|
|
128
|
+
stop("interrupted");
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
if (full()) {
|
|
132
|
+
stop("max-items", `collected ${store.size} items`);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (stats.steps >= limits.maxScrolls) {
|
|
136
|
+
stop("max-scrolls", `took ${stats.steps} scroll steps`);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (elapsed() >= limits.maxMs) {
|
|
140
|
+
stop("time-budget", `ran ${Math.round(elapsed() / 1000)} s`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const step = ++stats.steps;
|
|
144
|
+
const plan = planScrollStep(rng, pacing, before.viewportHeight);
|
|
145
|
+
let added = 0;
|
|
146
|
+
if (plan.back.length > 0) {
|
|
147
|
+
stats.scrollBacks++;
|
|
148
|
+
await driver.wheel(plan.back);
|
|
149
|
+
added += await harvest(step);
|
|
150
|
+
await pause(plan.backPauseMs);
|
|
151
|
+
}
|
|
152
|
+
if (await driver.wheel(plan.ticks))
|
|
153
|
+
stats.scrollFallbacks++;
|
|
154
|
+
added += await harvest(step);
|
|
155
|
+
if (plan.reading)
|
|
156
|
+
stats.readingPauses++;
|
|
157
|
+
// Split the pause so items rendered mid-pause are harvested too.
|
|
158
|
+
const firstHalf = Math.round(plan.pauseMs * uniform(rng, { min: 0.35, max: 0.65 }));
|
|
159
|
+
await pause(firstHalf);
|
|
160
|
+
added += await harvest(step);
|
|
161
|
+
added += await expand(step);
|
|
162
|
+
await pause(plan.pauseMs - firstHalf);
|
|
163
|
+
added += await harvest(step);
|
|
164
|
+
const after = await driver.metrics();
|
|
165
|
+
options.onStep?.({ step, items: store.size, added, plan });
|
|
166
|
+
const grew = after.scrollHeight > before.scrollHeight;
|
|
167
|
+
const moved = after.scrollTop > before.scrollTop;
|
|
168
|
+
before = after;
|
|
169
|
+
if (added > 0 || grew) {
|
|
170
|
+
idle = 0;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (full())
|
|
174
|
+
continue;
|
|
175
|
+
const blocked = await driver.blocked();
|
|
176
|
+
if (blocked) {
|
|
177
|
+
stop("blocked", blocked);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
idle++;
|
|
181
|
+
if (idle >= limits.idleSteps) {
|
|
182
|
+
if (after.atBottom) {
|
|
183
|
+
stop("end-of-feed", `no new items or page growth for ${idle} steps at the bottom`);
|
|
184
|
+
}
|
|
185
|
+
else if (!moved) {
|
|
186
|
+
stop("idle", `the page stopped scrolling; no new items for ${idle} steps`);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
stop("idle", `no new items or page growth for ${idle} steps`);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const items = store.items();
|
|
195
|
+
stats.items = items.length;
|
|
196
|
+
stats.duplicates = store.duplicates;
|
|
197
|
+
stats.elapsedMs = Math.round(elapsed());
|
|
198
|
+
if (items.length === 0 && stats.stopDetail && stats.stopReason !== "blocked") {
|
|
199
|
+
stats.stopDetail += " (the item selector matched nothing)";
|
|
200
|
+
}
|
|
201
|
+
return { items, stats };
|
|
202
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field extractor grammar shared by presets and `--collect-field`.
|
|
3
|
+
*
|
|
4
|
+
* <name>=<selector>[@<attr>]
|
|
5
|
+
* <name>[]=<selector>[@<attr>] every match, as a de-duplicated array
|
|
6
|
+
*
|
|
7
|
+
* The selector runs inside each item. An empty selector or `:scope` means the
|
|
8
|
+
* item itself. Without `@attr` the value is the element's visible text; with
|
|
9
|
+
* it, the attribute (href and src resolve to absolute URLs).
|
|
10
|
+
*/
|
|
11
|
+
export interface Extractor {
|
|
12
|
+
/** CSS selector relative to the item; "" means the item itself. */
|
|
13
|
+
selector: string;
|
|
14
|
+
/** Attribute to read; null reads visible text. */
|
|
15
|
+
attr: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface FieldSpec extends Extractor {
|
|
18
|
+
name: string;
|
|
19
|
+
/** Collect every match as an array instead of the first one. */
|
|
20
|
+
all: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Item properties the collector owns; fields cannot reuse these names. */
|
|
23
|
+
export declare const RESERVED_FIELD_NAMES: Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Parse `<selector>[@<attr>]`. The attribute separator is the last `@` that
|
|
26
|
+
* sits outside quotes and brackets, so `a[href*="@"]@href` works.
|
|
27
|
+
*/
|
|
28
|
+
export declare function parseExtractor(spec: string): Extractor;
|
|
29
|
+
/** Parse `<name>=<extractor>` or `<name>[]=<extractor>`. */
|
|
30
|
+
export declare function parseFieldSpec(raw: string): FieldSpec;
|
|
31
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../../../../src/lib/browser/collect/fields.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,SAAS;IACxB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,GAAG,EAAE,OAAO,CAAC;CACd;AAED,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,aAAoC,CAAC;AAKtE;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CA6BtD;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAoBrD"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field extractor grammar shared by presets and `--collect-field`.
|
|
3
|
+
*
|
|
4
|
+
* <name>=<selector>[@<attr>]
|
|
5
|
+
* <name>[]=<selector>[@<attr>] every match, as a de-duplicated array
|
|
6
|
+
*
|
|
7
|
+
* The selector runs inside each item. An empty selector or `:scope` means the
|
|
8
|
+
* item itself. Without `@attr` the value is the element's visible text; with
|
|
9
|
+
* it, the attribute (href and src resolve to absolute URLs).
|
|
10
|
+
*/
|
|
11
|
+
/** Item properties the collector owns; fields cannot reuse these names. */
|
|
12
|
+
export const RESERVED_FIELD_NAMES = new Set(["key", "firstSeenStep"]);
|
|
13
|
+
const NAME_RE = /^[A-Za-z_][\w-]*$/;
|
|
14
|
+
const ATTR_RE = /^[A-Za-z_:][-\w:.]*$/;
|
|
15
|
+
/**
|
|
16
|
+
* Parse `<selector>[@<attr>]`. The attribute separator is the last `@` that
|
|
17
|
+
* sits outside quotes and brackets, so `a[href*="@"]@href` works.
|
|
18
|
+
*/
|
|
19
|
+
export function parseExtractor(spec) {
|
|
20
|
+
const text = spec.trim();
|
|
21
|
+
let at = -1;
|
|
22
|
+
let quote = null;
|
|
23
|
+
let depth = 0;
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
const ch = text[i];
|
|
26
|
+
if (quote) {
|
|
27
|
+
if (ch === "\\")
|
|
28
|
+
i++;
|
|
29
|
+
else if (ch === quote)
|
|
30
|
+
quote = null;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === '"' || ch === "'")
|
|
34
|
+
quote = ch;
|
|
35
|
+
else if (ch === "[" || ch === "(")
|
|
36
|
+
depth++;
|
|
37
|
+
else if (ch === "]" || ch === ")")
|
|
38
|
+
depth = Math.max(0, depth - 1);
|
|
39
|
+
else if (ch === "@" && depth === 0)
|
|
40
|
+
at = i;
|
|
41
|
+
}
|
|
42
|
+
let selector = text;
|
|
43
|
+
let attr = null;
|
|
44
|
+
if (at >= 0) {
|
|
45
|
+
const candidate = text.slice(at + 1).trim();
|
|
46
|
+
if (!ATTR_RE.test(candidate)) {
|
|
47
|
+
throw new Error(`Extractor "${spec}": "${candidate}" is not a valid attribute name.`);
|
|
48
|
+
}
|
|
49
|
+
attr = candidate;
|
|
50
|
+
selector = text.slice(0, at).trim();
|
|
51
|
+
}
|
|
52
|
+
if (selector === ":scope")
|
|
53
|
+
selector = "";
|
|
54
|
+
return { selector, attr };
|
|
55
|
+
}
|
|
56
|
+
/** Parse `<name>=<extractor>` or `<name>[]=<extractor>`. */
|
|
57
|
+
export function parseFieldSpec(raw) {
|
|
58
|
+
const eq = raw.indexOf("=");
|
|
59
|
+
if (eq <= 0) {
|
|
60
|
+
throw new Error(`--collect-field expects <name>=<selector>[@attr] (got "${raw}"). Example: time=time@datetime`);
|
|
61
|
+
}
|
|
62
|
+
let name = raw.slice(0, eq).trim();
|
|
63
|
+
let all = false;
|
|
64
|
+
if (name.endsWith("[]")) {
|
|
65
|
+
all = true;
|
|
66
|
+
name = name.slice(0, -2);
|
|
67
|
+
}
|
|
68
|
+
if (!NAME_RE.test(name)) {
|
|
69
|
+
throw new Error(`Field name "${name}" must start with a letter or _ and use word characters.`);
|
|
70
|
+
}
|
|
71
|
+
if (RESERVED_FIELD_NAMES.has(name)) {
|
|
72
|
+
throw new Error(`Field name "${name}" is reserved by the collector.`);
|
|
73
|
+
}
|
|
74
|
+
return { name, all, ...parseExtractor(raw.slice(eq + 1)) };
|
|
75
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { type CollectDriver, type CollectedItem, type CollectLimits, type CollectResult, type CollectRunOptions, type CollectStats, DEFAULT_COLLECT_LIMITS, type HarvestedItem, ItemStore, type PageMetrics, runCollect, type StopReason, } from "./collector.js";
|
|
2
|
+
export { type Extractor, type FieldSpec, parseExtractor, parseFieldSpec, RESERVED_FIELD_NAMES, } from "./fields.js";
|
|
3
|
+
export { createPageDriver, type PageDriverDeps } from "./page-driver.js";
|
|
4
|
+
export { COLLECT_PRESETS, type CollectPreset, type CollectSpecOverrides, presetNames, type ResolvedCollectSpec, resolveCollectSpec, } from "./presets.js";
|
|
5
|
+
export { createRng, DEFAULT_SCROLL_PACING, PACING_LIMITS, parseProbability, parseRange, planScrollStep, type Range, type Rng, randomSeed, type ScrollPacing, type ScrollStepPlan, scrollPacingFromEnv, uniform, type WheelTick, wheelTicks, } from "./scroll-plan.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/browser/collect/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,sBAAsB,EACtB,KAAK,aAAa,EAClB,SAAS,EACT,KAAK,WAAW,EAChB,UAAU,EACV,KAAK,UAAU,GAChB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,KAAK,SAAS,EACd,KAAK,SAAS,EACd,cAAc,EACd,cAAc,EACd,oBAAoB,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,gBAAgB,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,EACL,eAAe,EACf,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,WAAW,EACX,KAAK,mBAAmB,EACxB,kBAAkB,GACnB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,SAAS,EACT,qBAAqB,EACrB,aAAa,EACb,gBAAgB,EAChB,UAAU,EACV,cAAc,EACd,KAAK,KAAK,EACV,KAAK,GAAG,EACR,UAAU,EACV,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,mBAAmB,EACnB,OAAO,EACP,KAAK,SAAS,EACd,UAAU,GACX,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DEFAULT_COLLECT_LIMITS, ItemStore, runCollect, } from "./collector.js";
|
|
2
|
+
export { parseExtractor, parseFieldSpec, RESERVED_FIELD_NAMES, } from "./fields.js";
|
|
3
|
+
export { createPageDriver } from "./page-driver.js";
|
|
4
|
+
export { COLLECT_PRESETS, presetNames, resolveCollectSpec, } from "./presets.js";
|
|
5
|
+
export { createRng, DEFAULT_SCROLL_PACING, PACING_LIMITS, parseProbability, parseRange, planScrollStep, randomSeed, scrollPacingFromEnv, uniform, wheelTicks, } from "./scroll-plan.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { CollectDriver } from "./collector.js";
|
|
3
|
+
import type { ResolvedCollectSpec } from "./presets.js";
|
|
4
|
+
import { type Range, type Rng } from "./scroll-plan.js";
|
|
5
|
+
export interface PageDriverDeps {
|
|
6
|
+
rng: Rng;
|
|
7
|
+
sleep?: (ms: number) => Promise<void>;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
log?: (message: string) => void;
|
|
10
|
+
/** Pause before each expand click, in ms (default 250-700). */
|
|
11
|
+
expandPauseMs?: Range;
|
|
12
|
+
}
|
|
13
|
+
export declare function createPageDriver(page: Page, spec: ResolvedCollectSpec, deps: PageDriverDeps): CollectDriver;
|
|
14
|
+
//# sourceMappingURL=page-driver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-driver.d.ts","sourceRoot":"","sources":["../../../../src/lib/browser/collect/page-driver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,EAAE,aAAa,EAA8B,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,KAAK,KAAK,EAAE,KAAK,GAAG,EAA2B,MAAM,kBAAkB,CAAC;AAmKjF,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,KAAK,CAAC;CACvB;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,mBAAmB,EACzB,IAAI,EAAE,cAAc,GACnB,aAAa,CAmJf"}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { uniform } from "./scroll-plan.js";
|
|
2
|
+
/** Attribute the driver puts on expand buttons it has handled. */
|
|
3
|
+
const EXPAND_MARK = "data-harn-collect-expand";
|
|
4
|
+
/** Generic signs of a rate limit or login wall, checked for every preset. */
|
|
5
|
+
const GENERIC_BLOCKED_TEXT = [
|
|
6
|
+
"rate limit exceeded",
|
|
7
|
+
"too many requests",
|
|
8
|
+
"(log in|sign in|sign up) to (continue|see more|keep (reading|scrolling))",
|
|
9
|
+
];
|
|
10
|
+
/** Words that mark a button as an action rather than a "show more" toggle. */
|
|
11
|
+
const UNSAFE_EXPAND_WORDS = "like|unlike|follow|reply|repost|retweet|share|subscribe|join|delete|block|report|mute|send|post|vote|bookmark|save";
|
|
12
|
+
/** Runs in the page. Must stay self-contained: no closures over module scope. */
|
|
13
|
+
function harvestInPage(args) {
|
|
14
|
+
const urlish = /href|src|permalink|url/i;
|
|
15
|
+
const read = (el, attr) => {
|
|
16
|
+
if (attr === null) {
|
|
17
|
+
const text = el.innerText ?? el.textContent ?? "";
|
|
18
|
+
return text.trim() || null;
|
|
19
|
+
}
|
|
20
|
+
let value = el.getAttribute(attr);
|
|
21
|
+
if (value === null)
|
|
22
|
+
return null;
|
|
23
|
+
value = value.trim();
|
|
24
|
+
if (value && urlish.test(attr) && !/^(data|blob|javascript):/i.test(value)) {
|
|
25
|
+
try {
|
|
26
|
+
value = new URL(value, location.href).href;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Keep the raw attribute when it is not a URL.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return value || null;
|
|
33
|
+
};
|
|
34
|
+
const pick = (root, selector) => {
|
|
35
|
+
if (!selector)
|
|
36
|
+
return [root];
|
|
37
|
+
try {
|
|
38
|
+
return Array.from(root.querySelectorAll(selector));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const first = (root, selector, attr) => {
|
|
45
|
+
for (const el of pick(root, selector)) {
|
|
46
|
+
const v = read(el, attr);
|
|
47
|
+
if (v)
|
|
48
|
+
return v;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
const permalinkRe = args.keyPattern
|
|
53
|
+
? new RegExp(args.keyPattern)
|
|
54
|
+
: /\/(status|statuses|post|posts|p|reel|reels|video|videos|comments|watch|item|items|story|stories|article|articles|entry|thread|threads)\/[^/?#]+|\/@[^/]+\/\d+|[?&](v|id|lc|p|story_fbid)=[\w-]+/i;
|
|
55
|
+
const here = location.href.split("#")[0];
|
|
56
|
+
const permalink = (item) => {
|
|
57
|
+
const anchors = item.matches("a[href]") ? [item] : [];
|
|
58
|
+
anchors.push(...Array.from(item.querySelectorAll("a[href]")));
|
|
59
|
+
for (const a of anchors) {
|
|
60
|
+
const href = a.href;
|
|
61
|
+
if (!href || href.split("#")[0] === here)
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(href);
|
|
65
|
+
if (permalinkRe.test(u.pathname + u.search))
|
|
66
|
+
return u.href.split("#")[0] ?? null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore malformed hrefs.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
const textHash = (text) => {
|
|
75
|
+
let h = 0x811c9dc5;
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
h ^= text.charCodeAt(i);
|
|
78
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return `text:${h.toString(16).padStart(8, "0")}`;
|
|
81
|
+
};
|
|
82
|
+
let items = [];
|
|
83
|
+
try {
|
|
84
|
+
items = Array.from(document.querySelectorAll(args.item));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
let key = args.key ? first(item, args.key.selector, args.key.attr) : null;
|
|
92
|
+
if (key && args.key?.attr && urlish.test(args.key.attr))
|
|
93
|
+
key = key.split("#")[0] ?? key;
|
|
94
|
+
if (!key)
|
|
95
|
+
key = permalink(item);
|
|
96
|
+
if (!key) {
|
|
97
|
+
const text = (item.innerText ?? "").replace(/\s+/g, " ").trim();
|
|
98
|
+
if (!text)
|
|
99
|
+
continue; // A skeleton or placeholder row: nothing to key on yet.
|
|
100
|
+
key = textHash(text);
|
|
101
|
+
}
|
|
102
|
+
const fields = {};
|
|
103
|
+
for (const f of args.fields) {
|
|
104
|
+
if (f.all) {
|
|
105
|
+
const values = [];
|
|
106
|
+
for (const el of pick(item, f.selector)) {
|
|
107
|
+
const v = read(el, f.attr);
|
|
108
|
+
if (v && !values.includes(v))
|
|
109
|
+
values.push(v);
|
|
110
|
+
}
|
|
111
|
+
fields[f.name] = values;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
fields[f.name] = first(item, f.selector, f.attr);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
out.push({ key, fields });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Runs in the page: scroll state of the element that actually scrolls the feed. */
|
|
122
|
+
function metricsInPage(itemSelector) {
|
|
123
|
+
const doc = document.scrollingElement ?? document.documentElement;
|
|
124
|
+
let container = doc;
|
|
125
|
+
let firstItem = null;
|
|
126
|
+
try {
|
|
127
|
+
firstItem = document.querySelector(itemSelector);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
firstItem = null;
|
|
131
|
+
}
|
|
132
|
+
for (let el = firstItem?.parentElement ?? null; el && el !== document.body; el = el.parentElement) {
|
|
133
|
+
const oy = getComputedStyle(el).overflowY;
|
|
134
|
+
if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {
|
|
135
|
+
container = el;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const isDoc = container === doc;
|
|
140
|
+
const scrollTop = isDoc ? window.scrollY : container.scrollTop;
|
|
141
|
+
const viewportHeight = isDoc ? window.innerHeight : container.clientHeight;
|
|
142
|
+
const scrollHeight = container.scrollHeight;
|
|
143
|
+
let pointerX = window.innerWidth / 2;
|
|
144
|
+
let pointerY = window.innerHeight / 2;
|
|
145
|
+
const target = firstItem ?? (isDoc ? null : container);
|
|
146
|
+
if (target) {
|
|
147
|
+
const r = (isDoc ? target : container).getBoundingClientRect();
|
|
148
|
+
pointerX = Math.min(Math.max(r.left + r.width / 2, 1), window.innerWidth - 1);
|
|
149
|
+
if (!isDoc)
|
|
150
|
+
pointerY = Math.min(Math.max(r.top + r.height / 2, 1), window.innerHeight - 1);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
scrollTop,
|
|
154
|
+
scrollHeight,
|
|
155
|
+
viewportHeight,
|
|
156
|
+
atBottom: scrollTop + viewportHeight >= scrollHeight - 4,
|
|
157
|
+
pointerX,
|
|
158
|
+
pointerY,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function createPageDriver(page, spec, deps) {
|
|
162
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
163
|
+
const now = deps.now ?? (() => performance.now());
|
|
164
|
+
const harvestArgs = {
|
|
165
|
+
item: spec.item,
|
|
166
|
+
key: spec.key,
|
|
167
|
+
keyPattern: spec.keyPattern,
|
|
168
|
+
fields: spec.fields.map(({ name, selector, attr, all }) => ({ name, selector, attr, all })),
|
|
169
|
+
};
|
|
170
|
+
let expandEnabled = spec.expand !== null;
|
|
171
|
+
let expandSeq = 0;
|
|
172
|
+
let pointer = null;
|
|
173
|
+
const metrics = async () => await page.evaluate(metricsInPage, spec.item);
|
|
174
|
+
return {
|
|
175
|
+
async harvest() {
|
|
176
|
+
return await page.evaluate(harvestInPage, harvestArgs);
|
|
177
|
+
},
|
|
178
|
+
async metrics() {
|
|
179
|
+
const { scrollTop, scrollHeight, viewportHeight, atBottom } = await metrics();
|
|
180
|
+
return { scrollTop, scrollHeight, viewportHeight, atBottom };
|
|
181
|
+
},
|
|
182
|
+
async wheel(ticks) {
|
|
183
|
+
if (ticks.length === 0)
|
|
184
|
+
return false;
|
|
185
|
+
const start = await metrics();
|
|
186
|
+
// Rest the pointer over the feed, drifting a little between steps the
|
|
187
|
+
// way a hand does, so wheel events reach an inner scroller too.
|
|
188
|
+
const jitter = () => uniform(deps.rng, { min: -40, max: 40 });
|
|
189
|
+
const target = {
|
|
190
|
+
x: Math.max(1, start.pointerX + jitter()),
|
|
191
|
+
y: Math.max(1, start.pointerY + jitter()),
|
|
192
|
+
};
|
|
193
|
+
if (!pointer || Math.hypot(pointer.x - target.x, pointer.y - target.y) > 30) {
|
|
194
|
+
await page.mouse.move(target.x, target.y, { steps: 4 });
|
|
195
|
+
pointer = target;
|
|
196
|
+
}
|
|
197
|
+
for (const tick of ticks) {
|
|
198
|
+
await page.mouse.wheel(0, tick.deltaY);
|
|
199
|
+
await sleep(tick.gapMs);
|
|
200
|
+
}
|
|
201
|
+
const total = ticks.reduce((sum, t) => sum + t.deltaY, 0);
|
|
202
|
+
if (total <= 0)
|
|
203
|
+
return false;
|
|
204
|
+
// Wheel scrolling settles asynchronously; give it a beat before judging.
|
|
205
|
+
await sleep(120);
|
|
206
|
+
let after = await metrics();
|
|
207
|
+
if (after.scrollTop > start.scrollTop || after.atBottom)
|
|
208
|
+
return false;
|
|
209
|
+
await page.keyboard.press("PageDown").catch(() => { });
|
|
210
|
+
await sleep(150);
|
|
211
|
+
after = await metrics();
|
|
212
|
+
if (after.scrollTop > start.scrollTop || after.atBottom)
|
|
213
|
+
return true;
|
|
214
|
+
await page.evaluate(({ selector, dy }) => {
|
|
215
|
+
const first = document.querySelector(selector);
|
|
216
|
+
let el = first?.parentElement ?? null;
|
|
217
|
+
while (el && el !== document.body) {
|
|
218
|
+
const oy = getComputedStyle(el).overflowY;
|
|
219
|
+
if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {
|
|
220
|
+
el.scrollBy(0, dy);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
el = el.parentElement;
|
|
224
|
+
}
|
|
225
|
+
window.scrollBy(0, dy);
|
|
226
|
+
}, { selector: spec.item, dy: total });
|
|
227
|
+
return true;
|
|
228
|
+
},
|
|
229
|
+
async expand(limit) {
|
|
230
|
+
if (!expandEnabled || spec.expand === null || limit <= 0)
|
|
231
|
+
return 0;
|
|
232
|
+
const base = expandSeq;
|
|
233
|
+
const marked = await page.evaluate(({ item, expand, mark, unsafe, limit, base }) => {
|
|
234
|
+
const unsafeRe = new RegExp(`\\b(${unsafe})\\b`, "i");
|
|
235
|
+
const ids = [];
|
|
236
|
+
let seq = base;
|
|
237
|
+
for (const it of Array.from(document.querySelectorAll(item))) {
|
|
238
|
+
for (const el of Array.from(it.querySelectorAll(expand))) {
|
|
239
|
+
if (ids.length >= limit)
|
|
240
|
+
return ids;
|
|
241
|
+
if (el.hasAttribute(mark))
|
|
242
|
+
continue;
|
|
243
|
+
if (el.closest("a[href]"))
|
|
244
|
+
continue; // Links navigate away.
|
|
245
|
+
const label = `${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("data-testid") ?? ""} ${el.innerText ?? ""}`;
|
|
246
|
+
if (unsafeRe.test(label))
|
|
247
|
+
continue;
|
|
248
|
+
const r = el.getBoundingClientRect();
|
|
249
|
+
if (r.width === 0 || r.height === 0 || r.bottom < 0 || r.top > window.innerHeight) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const id = String(++seq);
|
|
253
|
+
el.setAttribute(mark, id);
|
|
254
|
+
ids.push(id);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return ids;
|
|
258
|
+
}, {
|
|
259
|
+
item: spec.item,
|
|
260
|
+
expand: spec.expand,
|
|
261
|
+
mark: EXPAND_MARK,
|
|
262
|
+
unsafe: UNSAFE_EXPAND_WORDS,
|
|
263
|
+
limit,
|
|
264
|
+
base,
|
|
265
|
+
});
|
|
266
|
+
expandSeq = base + marked.length;
|
|
267
|
+
let clicked = 0;
|
|
268
|
+
for (const id of marked) {
|
|
269
|
+
await sleep(Math.round(uniform(deps.rng, deps.expandPauseMs ?? { min: 250, max: 700 })));
|
|
270
|
+
const before = page.url();
|
|
271
|
+
try {
|
|
272
|
+
await page.locator(`[${EXPAND_MARK}="${id}"]`).click({ timeout: 2_000 });
|
|
273
|
+
clicked++;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (page.url() !== before) {
|
|
279
|
+
// The button navigated: go back to the feed and stop expanding.
|
|
280
|
+
deps.log?.(`collect: an expand click navigated to ${page.url()}; expansion disabled.`);
|
|
281
|
+
expandEnabled = false;
|
|
282
|
+
await page.goBack().catch(() => { });
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return clicked;
|
|
287
|
+
},
|
|
288
|
+
async blocked() {
|
|
289
|
+
return await page.evaluate(({ patterns }) => {
|
|
290
|
+
const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [aria-modal="true"], dialog[open]'))
|
|
291
|
+
.map((d) => d.innerText ?? "")
|
|
292
|
+
.join("\n");
|
|
293
|
+
const body = (document.body?.innerText ?? "").slice(0, 200_000);
|
|
294
|
+
for (const source of patterns) {
|
|
295
|
+
const re = new RegExp(source, "i");
|
|
296
|
+
const m = re.exec(dialogs) ?? re.exec(body);
|
|
297
|
+
if (m)
|
|
298
|
+
return `page shows "${m[0].slice(0, 80)}"`;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}, { patterns: [...spec.blockedText, ...GENERIC_BLOCKED_TEXT] });
|
|
302
|
+
},
|
|
303
|
+
sleep,
|
|
304
|
+
now,
|
|
305
|
+
};
|
|
306
|
+
}
|