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,327 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { CollectDriver, HarvestedItem, PageMetrics } from "./collector.ts";
|
|
3
|
+
import type { ResolvedCollectSpec } from "./presets.ts";
|
|
4
|
+
import { type Range, type Rng, uniform, type WheelTick } from "./scroll-plan.ts";
|
|
5
|
+
|
|
6
|
+
/** Attribute the driver puts on expand buttons it has handled. */
|
|
7
|
+
const EXPAND_MARK = "data-harn-collect-expand";
|
|
8
|
+
|
|
9
|
+
/** Generic signs of a rate limit or login wall, checked for every preset. */
|
|
10
|
+
const GENERIC_BLOCKED_TEXT = [
|
|
11
|
+
"rate limit exceeded",
|
|
12
|
+
"too many requests",
|
|
13
|
+
"(log in|sign in|sign up) to (continue|see more|keep (reading|scrolling))",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Words that mark a button as an action rather than a "show more" toggle. */
|
|
17
|
+
const UNSAFE_EXPAND_WORDS =
|
|
18
|
+
"like|unlike|follow|reply|repost|retweet|share|subscribe|join|delete|block|report|mute|send|post|vote|bookmark|save";
|
|
19
|
+
|
|
20
|
+
interface HarvestArgs {
|
|
21
|
+
item: string;
|
|
22
|
+
key: { selector: string; attr: string | null } | null;
|
|
23
|
+
keyPattern: string | null;
|
|
24
|
+
fields: Array<{ name: string; selector: string; attr: string | null; all: boolean }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs in the page. Must stay self-contained: no closures over module scope. */
|
|
28
|
+
function harvestInPage(args: HarvestArgs): Array<{ key: string; fields: Record<string, unknown> }> {
|
|
29
|
+
const urlish = /href|src|permalink|url/i;
|
|
30
|
+
const read = (el: Element, attr: string | null): string | null => {
|
|
31
|
+
if (attr === null) {
|
|
32
|
+
const text = (el as HTMLElement).innerText ?? el.textContent ?? "";
|
|
33
|
+
return text.trim() || null;
|
|
34
|
+
}
|
|
35
|
+
let value = el.getAttribute(attr);
|
|
36
|
+
if (value === null) return null;
|
|
37
|
+
value = value.trim();
|
|
38
|
+
if (value && urlish.test(attr) && !/^(data|blob|javascript):/i.test(value)) {
|
|
39
|
+
try {
|
|
40
|
+
value = new URL(value, location.href).href;
|
|
41
|
+
} catch {
|
|
42
|
+
// Keep the raw attribute when it is not a URL.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return value || null;
|
|
46
|
+
};
|
|
47
|
+
const pick = (root: Element, selector: string): Element[] => {
|
|
48
|
+
if (!selector) return [root];
|
|
49
|
+
try {
|
|
50
|
+
return Array.from(root.querySelectorAll(selector));
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const first = (root: Element, selector: string, attr: string | null): string | null => {
|
|
56
|
+
for (const el of pick(root, selector)) {
|
|
57
|
+
const v = read(el, attr);
|
|
58
|
+
if (v) return v;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
const permalinkRe = args.keyPattern
|
|
63
|
+
? new RegExp(args.keyPattern)
|
|
64
|
+
: /\/(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;
|
|
65
|
+
const here = location.href.split("#")[0];
|
|
66
|
+
const permalink = (item: Element): string | null => {
|
|
67
|
+
const anchors: Element[] = item.matches("a[href]") ? [item] : [];
|
|
68
|
+
anchors.push(...Array.from(item.querySelectorAll("a[href]")));
|
|
69
|
+
for (const a of anchors) {
|
|
70
|
+
const href = (a as HTMLAnchorElement).href;
|
|
71
|
+
if (!href || href.split("#")[0] === here) continue;
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(href);
|
|
74
|
+
if (permalinkRe.test(u.pathname + u.search)) return u.href.split("#")[0] ?? null;
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore malformed hrefs.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
const textHash = (text: string): string => {
|
|
82
|
+
let h = 0x811c9dc5;
|
|
83
|
+
for (let i = 0; i < text.length; i++) {
|
|
84
|
+
h ^= text.charCodeAt(i);
|
|
85
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
86
|
+
}
|
|
87
|
+
return `text:${h.toString(16).padStart(8, "0")}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
let items: Element[] = [];
|
|
91
|
+
try {
|
|
92
|
+
items = Array.from(document.querySelectorAll(args.item));
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const out: Array<{ key: string; fields: Record<string, unknown> }> = [];
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
let key = args.key ? first(item, args.key.selector, args.key.attr) : null;
|
|
99
|
+
if (key && args.key?.attr && urlish.test(args.key.attr)) key = key.split("#")[0] ?? key;
|
|
100
|
+
if (!key) key = permalink(item);
|
|
101
|
+
if (!key) {
|
|
102
|
+
const text = ((item as HTMLElement).innerText ?? "").replace(/\s+/g, " ").trim();
|
|
103
|
+
if (!text) continue; // A skeleton or placeholder row: nothing to key on yet.
|
|
104
|
+
key = textHash(text);
|
|
105
|
+
}
|
|
106
|
+
const fields: Record<string, unknown> = {};
|
|
107
|
+
for (const f of args.fields) {
|
|
108
|
+
if (f.all) {
|
|
109
|
+
const values: string[] = [];
|
|
110
|
+
for (const el of pick(item, f.selector)) {
|
|
111
|
+
const v = read(el, f.attr);
|
|
112
|
+
if (v && !values.includes(v)) values.push(v);
|
|
113
|
+
}
|
|
114
|
+
fields[f.name] = values;
|
|
115
|
+
} else {
|
|
116
|
+
fields[f.name] = first(item, f.selector, f.attr);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
out.push({ key, fields });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Runs in the page: scroll state of the element that actually scrolls the feed. */
|
|
125
|
+
function metricsInPage(itemSelector: string): PageMetrics & { pointerX: number; pointerY: number } {
|
|
126
|
+
const doc = document.scrollingElement ?? document.documentElement;
|
|
127
|
+
let container: Element = doc;
|
|
128
|
+
let firstItem: Element | null = null;
|
|
129
|
+
try {
|
|
130
|
+
firstItem = document.querySelector(itemSelector);
|
|
131
|
+
} catch {
|
|
132
|
+
firstItem = null;
|
|
133
|
+
}
|
|
134
|
+
for (
|
|
135
|
+
let el = firstItem?.parentElement ?? null;
|
|
136
|
+
el && el !== document.body;
|
|
137
|
+
el = el.parentElement
|
|
138
|
+
) {
|
|
139
|
+
const oy = getComputedStyle(el).overflowY;
|
|
140
|
+
if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {
|
|
141
|
+
container = el;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const isDoc = container === doc;
|
|
146
|
+
const scrollTop = isDoc ? window.scrollY : container.scrollTop;
|
|
147
|
+
const viewportHeight = isDoc ? window.innerHeight : container.clientHeight;
|
|
148
|
+
const scrollHeight = container.scrollHeight;
|
|
149
|
+
let pointerX = window.innerWidth / 2;
|
|
150
|
+
let pointerY = window.innerHeight / 2;
|
|
151
|
+
const target = firstItem ?? (isDoc ? null : container);
|
|
152
|
+
if (target) {
|
|
153
|
+
const r = (isDoc ? target : container).getBoundingClientRect();
|
|
154
|
+
pointerX = Math.min(Math.max(r.left + r.width / 2, 1), window.innerWidth - 1);
|
|
155
|
+
if (!isDoc) pointerY = Math.min(Math.max(r.top + r.height / 2, 1), window.innerHeight - 1);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
scrollTop,
|
|
159
|
+
scrollHeight,
|
|
160
|
+
viewportHeight,
|
|
161
|
+
atBottom: scrollTop + viewportHeight >= scrollHeight - 4,
|
|
162
|
+
pointerX,
|
|
163
|
+
pointerY,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface PageDriverDeps {
|
|
168
|
+
rng: Rng;
|
|
169
|
+
sleep?: (ms: number) => Promise<void>;
|
|
170
|
+
now?: () => number;
|
|
171
|
+
log?: (message: string) => void;
|
|
172
|
+
/** Pause before each expand click, in ms (default 250-700). */
|
|
173
|
+
expandPauseMs?: Range;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createPageDriver(
|
|
177
|
+
page: Page,
|
|
178
|
+
spec: ResolvedCollectSpec,
|
|
179
|
+
deps: PageDriverDeps,
|
|
180
|
+
): CollectDriver {
|
|
181
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
182
|
+
const now = deps.now ?? (() => performance.now());
|
|
183
|
+
const harvestArgs: HarvestArgs = {
|
|
184
|
+
item: spec.item,
|
|
185
|
+
key: spec.key,
|
|
186
|
+
keyPattern: spec.keyPattern,
|
|
187
|
+
fields: spec.fields.map(({ name, selector, attr, all }) => ({ name, selector, attr, all })),
|
|
188
|
+
};
|
|
189
|
+
let expandEnabled = spec.expand !== null;
|
|
190
|
+
let expandSeq = 0;
|
|
191
|
+
let pointer: { x: number; y: number } | null = null;
|
|
192
|
+
|
|
193
|
+
const metrics = async () => await page.evaluate(metricsInPage, spec.item);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
async harvest(): Promise<HarvestedItem[]> {
|
|
197
|
+
return await page.evaluate(harvestInPage, harvestArgs);
|
|
198
|
+
},
|
|
199
|
+
async metrics(): Promise<PageMetrics> {
|
|
200
|
+
const { scrollTop, scrollHeight, viewportHeight, atBottom } = await metrics();
|
|
201
|
+
return { scrollTop, scrollHeight, viewportHeight, atBottom };
|
|
202
|
+
},
|
|
203
|
+
async wheel(ticks: WheelTick[]): Promise<boolean> {
|
|
204
|
+
if (ticks.length === 0) return false;
|
|
205
|
+
const start = await metrics();
|
|
206
|
+
// Rest the pointer over the feed, drifting a little between steps the
|
|
207
|
+
// way a hand does, so wheel events reach an inner scroller too.
|
|
208
|
+
const jitter = () => uniform(deps.rng, { min: -40, max: 40 });
|
|
209
|
+
const target = {
|
|
210
|
+
x: Math.max(1, start.pointerX + jitter()),
|
|
211
|
+
y: Math.max(1, start.pointerY + jitter()),
|
|
212
|
+
};
|
|
213
|
+
if (!pointer || Math.hypot(pointer.x - target.x, pointer.y - target.y) > 30) {
|
|
214
|
+
await page.mouse.move(target.x, target.y, { steps: 4 });
|
|
215
|
+
pointer = target;
|
|
216
|
+
}
|
|
217
|
+
for (const tick of ticks) {
|
|
218
|
+
await page.mouse.wheel(0, tick.deltaY);
|
|
219
|
+
await sleep(tick.gapMs);
|
|
220
|
+
}
|
|
221
|
+
const total = ticks.reduce((sum, t) => sum + t.deltaY, 0);
|
|
222
|
+
if (total <= 0) return false;
|
|
223
|
+
// Wheel scrolling settles asynchronously; give it a beat before judging.
|
|
224
|
+
await sleep(120);
|
|
225
|
+
let after = await metrics();
|
|
226
|
+
if (after.scrollTop > start.scrollTop || after.atBottom) return false;
|
|
227
|
+
await page.keyboard.press("PageDown").catch(() => {});
|
|
228
|
+
await sleep(150);
|
|
229
|
+
after = await metrics();
|
|
230
|
+
if (after.scrollTop > start.scrollTop || after.atBottom) return true;
|
|
231
|
+
await page.evaluate(
|
|
232
|
+
({ selector, dy }) => {
|
|
233
|
+
const first = document.querySelector(selector);
|
|
234
|
+
let el = first?.parentElement ?? null;
|
|
235
|
+
while (el && el !== document.body) {
|
|
236
|
+
const oy = getComputedStyle(el).overflowY;
|
|
237
|
+
if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {
|
|
238
|
+
el.scrollBy(0, dy);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
el = el.parentElement;
|
|
242
|
+
}
|
|
243
|
+
window.scrollBy(0, dy);
|
|
244
|
+
},
|
|
245
|
+
{ selector: spec.item, dy: total },
|
|
246
|
+
);
|
|
247
|
+
return true;
|
|
248
|
+
},
|
|
249
|
+
async expand(limit: number): Promise<number> {
|
|
250
|
+
if (!expandEnabled || spec.expand === null || limit <= 0) return 0;
|
|
251
|
+
const base = expandSeq;
|
|
252
|
+
const marked: string[] = await page.evaluate(
|
|
253
|
+
({ item, expand, mark, unsafe, limit, base }) => {
|
|
254
|
+
const unsafeRe = new RegExp(`\\b(${unsafe})\\b`, "i");
|
|
255
|
+
const ids: string[] = [];
|
|
256
|
+
let seq = base;
|
|
257
|
+
for (const it of Array.from(document.querySelectorAll(item))) {
|
|
258
|
+
for (const el of Array.from(it.querySelectorAll(expand))) {
|
|
259
|
+
if (ids.length >= limit) return ids;
|
|
260
|
+
if (el.hasAttribute(mark)) continue;
|
|
261
|
+
if (el.closest("a[href]")) continue; // Links navigate away.
|
|
262
|
+
const label = `${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("data-testid") ?? ""} ${(el as HTMLElement).innerText ?? ""}`;
|
|
263
|
+
if (unsafeRe.test(label)) continue;
|
|
264
|
+
const r = el.getBoundingClientRect();
|
|
265
|
+
if (r.width === 0 || r.height === 0 || r.bottom < 0 || r.top > window.innerHeight) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const id = String(++seq);
|
|
269
|
+
el.setAttribute(mark, id);
|
|
270
|
+
ids.push(id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return ids;
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
item: spec.item,
|
|
277
|
+
expand: spec.expand,
|
|
278
|
+
mark: EXPAND_MARK,
|
|
279
|
+
unsafe: UNSAFE_EXPAND_WORDS,
|
|
280
|
+
limit,
|
|
281
|
+
base,
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
expandSeq = base + marked.length;
|
|
285
|
+
let clicked = 0;
|
|
286
|
+
for (const id of marked) {
|
|
287
|
+
await sleep(Math.round(uniform(deps.rng, deps.expandPauseMs ?? { min: 250, max: 700 })));
|
|
288
|
+
const before = page.url();
|
|
289
|
+
try {
|
|
290
|
+
await page.locator(`[${EXPAND_MARK}="${id}"]`).click({ timeout: 2_000 });
|
|
291
|
+
clicked++;
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (page.url() !== before) {
|
|
296
|
+
// The button navigated: go back to the feed and stop expanding.
|
|
297
|
+
deps.log?.(`collect: an expand click navigated to ${page.url()}; expansion disabled.`);
|
|
298
|
+
expandEnabled = false;
|
|
299
|
+
await page.goBack().catch(() => {});
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return clicked;
|
|
304
|
+
},
|
|
305
|
+
async blocked(): Promise<string | null> {
|
|
306
|
+
return await page.evaluate(
|
|
307
|
+
({ patterns }) => {
|
|
308
|
+
const dialogs = Array.from(
|
|
309
|
+
document.querySelectorAll('[role="dialog"], [aria-modal="true"], dialog[open]'),
|
|
310
|
+
)
|
|
311
|
+
.map((d) => (d as HTMLElement).innerText ?? "")
|
|
312
|
+
.join("\n");
|
|
313
|
+
const body = (document.body?.innerText ?? "").slice(0, 200_000);
|
|
314
|
+
for (const source of patterns) {
|
|
315
|
+
const re = new RegExp(source, "i");
|
|
316
|
+
const m = re.exec(dialogs) ?? re.exec(body);
|
|
317
|
+
if (m) return `page shows "${m[0].slice(0, 80)}"`;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
},
|
|
321
|
+
{ patterns: [...spec.blockedText, ...GENERIC_BLOCKED_TEXT] },
|
|
322
|
+
);
|
|
323
|
+
},
|
|
324
|
+
sleep,
|
|
325
|
+
now,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { type Extractor, type FieldSpec, parseExtractor, parseFieldSpec } from "./fields.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site presets for `browse --collect <preset>`.
|
|
5
|
+
*
|
|
6
|
+
* A preset is data, not code: an item selector, an optional key extractor, a
|
|
7
|
+
* map of field extractors in the `--collect-field` grammar, an optional
|
|
8
|
+
* "show more" selector for `--collect-expand`, and text patterns that mean the
|
|
9
|
+
* site has stopped serving the feed. Adding a site is one entry here.
|
|
10
|
+
*
|
|
11
|
+
* `tested: false` marks presets written from each site's public markup but not
|
|
12
|
+
* exercised against the live site. Feed markup changes often; override any
|
|
13
|
+
* part with `--collect-item`, `--collect-key` and `--collect-field`.
|
|
14
|
+
*/
|
|
15
|
+
export interface CollectPreset {
|
|
16
|
+
description: string;
|
|
17
|
+
/** True when the preset has been exercised against the live site. */
|
|
18
|
+
tested: boolean;
|
|
19
|
+
/** CSS selector matching one feed item. */
|
|
20
|
+
item: string;
|
|
21
|
+
/** Extractor for the dedupe key; falls back to the permalink heuristic, then a text hash. */
|
|
22
|
+
key?: string;
|
|
23
|
+
/** Regex (source) a link must match for the permalink heuristic. */
|
|
24
|
+
keyPattern?: string;
|
|
25
|
+
/** Field name (suffix [] for arrays) to extractor. */
|
|
26
|
+
fields: Record<string, string>;
|
|
27
|
+
/** Selector for "show more" style buttons inside an item. */
|
|
28
|
+
expand?: string;
|
|
29
|
+
/** Regexes (source, case-insensitive) that mean a login wall or rate limit. */
|
|
30
|
+
blockedText?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const COLLECT_PRESETS: Record<string, CollectPreset> = {
|
|
34
|
+
generic: {
|
|
35
|
+
description:
|
|
36
|
+
"Any feed of <article> or role=article items; key is the first permalink-like link.",
|
|
37
|
+
tested: true,
|
|
38
|
+
item: 'article, [role="article"]',
|
|
39
|
+
fields: { text: "", time: "time@datetime" },
|
|
40
|
+
},
|
|
41
|
+
x: {
|
|
42
|
+
description: "X (Twitter) timelines, searches, profiles and threads.",
|
|
43
|
+
tested: true,
|
|
44
|
+
item: 'article[data-testid="tweet"]',
|
|
45
|
+
key: "a:has(> time)@href",
|
|
46
|
+
fields: {
|
|
47
|
+
url: "a:has(> time)@href",
|
|
48
|
+
time: "time@datetime",
|
|
49
|
+
author: '[data-testid="User-Name"]',
|
|
50
|
+
text: '[data-testid="tweetText"]',
|
|
51
|
+
"media[]": '[data-testid="tweetPhoto"] img@src',
|
|
52
|
+
},
|
|
53
|
+
expand: 'button[data-testid="tweet-text-show-more-link"]',
|
|
54
|
+
blockedText: ["Something went wrong\\. Try reloading", "Rate limit exceeded"],
|
|
55
|
+
},
|
|
56
|
+
threads: {
|
|
57
|
+
description: "Threads (threads.net / threads.com) feeds and profiles.",
|
|
58
|
+
tested: false,
|
|
59
|
+
item: 'div[data-pressable-container="true"]',
|
|
60
|
+
keyPattern: "/post/[^/?#]+",
|
|
61
|
+
fields: {
|
|
62
|
+
url: 'a[href*="/post/"]@href',
|
|
63
|
+
time: "time@datetime",
|
|
64
|
+
author: 'a[href^="/@"]',
|
|
65
|
+
text: "",
|
|
66
|
+
"media[]": "img[src]@src",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
bluesky: {
|
|
70
|
+
description: "Bluesky (bsky.app) feeds, searches and profiles.",
|
|
71
|
+
tested: false,
|
|
72
|
+
item: '[data-testid^="feedItem-by-"]',
|
|
73
|
+
key: 'a[href*="/post/"]@href',
|
|
74
|
+
fields: {
|
|
75
|
+
url: 'a[href*="/post/"]@href',
|
|
76
|
+
author: 'a[href^="/profile/"]',
|
|
77
|
+
text: '[data-testid="postText"]',
|
|
78
|
+
"media[]": 'img[src*="/img/feed"]@src',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
reddit: {
|
|
82
|
+
description: "Reddit (new shreddit UI) subreddit, search and home feeds.",
|
|
83
|
+
tested: false,
|
|
84
|
+
item: "shreddit-post",
|
|
85
|
+
key: "@permalink",
|
|
86
|
+
fields: {
|
|
87
|
+
url: "@permalink",
|
|
88
|
+
title: "@post-title",
|
|
89
|
+
author: "@author",
|
|
90
|
+
time: "@created-timestamp",
|
|
91
|
+
score: "@score",
|
|
92
|
+
comments: "@comment-count",
|
|
93
|
+
text: '[slot="text-body"]',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
linkedin: {
|
|
97
|
+
description: "LinkedIn home and search feeds (signed in).",
|
|
98
|
+
tested: false,
|
|
99
|
+
item: "div.feed-shared-update-v2",
|
|
100
|
+
key: "@data-urn",
|
|
101
|
+
fields: {
|
|
102
|
+
author: ".update-components-actor__title",
|
|
103
|
+
time: ".update-components-actor__sub-description",
|
|
104
|
+
text: ".update-components-text",
|
|
105
|
+
},
|
|
106
|
+
expand: "button.feed-shared-inline-show-more-text__see-more-less-toggle",
|
|
107
|
+
},
|
|
108
|
+
instagram: {
|
|
109
|
+
description: "Instagram home feed posts.",
|
|
110
|
+
tested: false,
|
|
111
|
+
item: "article",
|
|
112
|
+
keyPattern: "/(p|reel)/[^/?#]+",
|
|
113
|
+
fields: {
|
|
114
|
+
url: 'a[href*="/p/"]@href',
|
|
115
|
+
time: "time@datetime",
|
|
116
|
+
author: 'header a[href^="/"]',
|
|
117
|
+
text: "h1",
|
|
118
|
+
"media[]": "img[src]@src",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
tiktok: {
|
|
122
|
+
description: "TikTok web For You, search and profile grids.",
|
|
123
|
+
tested: false,
|
|
124
|
+
item: '[data-e2e="recommend-list-item-container"], [data-e2e="search_top-item"], [data-e2e="user-post-item"]',
|
|
125
|
+
key: 'a[href*="/video/"]@href',
|
|
126
|
+
fields: {
|
|
127
|
+
url: 'a[href*="/video/"]@href',
|
|
128
|
+
author: '[data-e2e="video-author-uniqueid"]',
|
|
129
|
+
text: '[data-e2e="video-desc"]',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
mastodon: {
|
|
133
|
+
description: "Mastodon web UI timelines and profiles (any instance).",
|
|
134
|
+
tested: false,
|
|
135
|
+
item: "article",
|
|
136
|
+
key: "a.status__relative-time@href",
|
|
137
|
+
fields: {
|
|
138
|
+
url: "a.status__relative-time@href",
|
|
139
|
+
time: "time@datetime",
|
|
140
|
+
author: ".display-name__account",
|
|
141
|
+
text: ".status__content",
|
|
142
|
+
"media[]": ".media-gallery img@src",
|
|
143
|
+
},
|
|
144
|
+
expand: "button.status__content__spoiler-link",
|
|
145
|
+
},
|
|
146
|
+
"youtube-comments": {
|
|
147
|
+
description: "YouTube watch-page comment threads (scroll past the video first).",
|
|
148
|
+
tested: false,
|
|
149
|
+
item: "ytd-comment-thread-renderer",
|
|
150
|
+
key: '#published-time-text a, .published-time-text a, a[href*="lc="]@href',
|
|
151
|
+
fields: {
|
|
152
|
+
url: 'a[href*="lc="]@href',
|
|
153
|
+
author: "#author-text",
|
|
154
|
+
time: '#published-time-text, .published-time-text, a[href*="lc="]',
|
|
155
|
+
text: "#content-text",
|
|
156
|
+
likes: "#vote-count-middle",
|
|
157
|
+
},
|
|
158
|
+
expand: "tp-yt-paper-button#more, #more.ytd-expander",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** What the page driver needs: every string parsed and validated. */
|
|
163
|
+
export interface ResolvedCollectSpec {
|
|
164
|
+
preset: string;
|
|
165
|
+
item: string;
|
|
166
|
+
key: Extractor | null;
|
|
167
|
+
keyPattern: string | null;
|
|
168
|
+
fields: FieldSpec[];
|
|
169
|
+
expand: string | null;
|
|
170
|
+
blockedText: string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface CollectSpecOverrides {
|
|
174
|
+
item?: string;
|
|
175
|
+
key?: string;
|
|
176
|
+
keyPattern?: string;
|
|
177
|
+
fields?: string[];
|
|
178
|
+
/** A selector, or true to use the preset's selector. */
|
|
179
|
+
expand?: string | boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function presetNames(): string[] {
|
|
183
|
+
return Object.keys(COLLECT_PRESETS);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function resolveCollectSpec(
|
|
187
|
+
presetName: string,
|
|
188
|
+
overrides: CollectSpecOverrides = {},
|
|
189
|
+
): ResolvedCollectSpec {
|
|
190
|
+
const preset = COLLECT_PRESETS[presetName];
|
|
191
|
+
if (!preset) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Unknown collect preset "${presetName}". Available: ${presetNames().join(", ")}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const fields = new Map<string, FieldSpec>();
|
|
197
|
+
for (const [name, spec] of Object.entries(preset.fields)) {
|
|
198
|
+
const field = parseFieldSpec(`${name}=${spec}`);
|
|
199
|
+
fields.set(field.name, field);
|
|
200
|
+
}
|
|
201
|
+
for (const raw of overrides.fields ?? []) {
|
|
202
|
+
const field = parseFieldSpec(raw);
|
|
203
|
+
fields.set(field.name, field);
|
|
204
|
+
}
|
|
205
|
+
const keySpec = overrides.key ?? preset.key;
|
|
206
|
+
const keyPattern = overrides.keyPattern ?? preset.keyPattern ?? null;
|
|
207
|
+
if (keyPattern !== null) {
|
|
208
|
+
try {
|
|
209
|
+
new RegExp(keyPattern);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Key pattern "${keyPattern}" is not a valid regex: ${(err as Error).message}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
let expand: string | null = null;
|
|
217
|
+
if (typeof overrides.expand === "string" && overrides.expand.trim()) {
|
|
218
|
+
expand = overrides.expand.trim();
|
|
219
|
+
} else if (overrides.expand) {
|
|
220
|
+
if (!preset.expand) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Preset "${presetName}" has no expand selector; pass one: --collect-expand '<selector>'.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
expand = preset.expand;
|
|
226
|
+
}
|
|
227
|
+
const item = overrides.item?.trim() || preset.item;
|
|
228
|
+
return {
|
|
229
|
+
preset: presetName,
|
|
230
|
+
item,
|
|
231
|
+
key: keySpec === undefined ? null : parseExtractor(keySpec),
|
|
232
|
+
keyPattern,
|
|
233
|
+
fields: [...fields.values()],
|
|
234
|
+
expand,
|
|
235
|
+
blockedText: preset.blockedText ?? [],
|
|
236
|
+
};
|
|
237
|
+
}
|