Social Runtime Crawler v0.1 — browser-native self-learning social crawler PoC
Monorepo (pnpm, strict TS via tsx, vitest): persistent Chromium sessions with human login, network observer + SchemaProfiler + generic JSON entity miner, DOM mutation observer + in-page semantic snapshot, page classifier, network/DOM surface merge with provenance, video detection and frame sampling, world model, information-gain heuristic planner (+ optional Anthropic/local LLM planner), loop detection, platform-model learning and connector compilation, PostgreSQL + JSONL replay storage, research dashboard, YouTube and Reddit adapters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
67 changed files +6,657 −0
added
.env.example
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +# Social Runtime Crawler — environment | |
| 2 | +# Copy to .env (never commit .env). No platform passwords belong here or anywhere in the repo: | |
| 3 | +# authentication is done by a human once in the headed browser (`pnpm login <platform>`). | |
| 4 | + | |
| 5 | +# PostgreSQL (prototype storage). Leave empty to run with the JSONL-only store. | |
| 6 | +SRC_DATABASE_URL=postgres://localhost:5432/social_runtime | |
| 7 | + | |
| 8 | +# Where browser profiles, session logs and learned platform models live. | |
| 9 | +SRC_DATA_DIR=./data | |
| 10 | + | |
| 11 | +# LLM planner (Tier 4). If no provider is configured the heuristic planner (Tier 1) is used alone. | |
| 12 | +# anthropic → uses ANTHROPIC_API_KEY (or an `ant auth login` profile) | |
| 13 | +# local → OpenAI-compatible local endpoint (e.g. llm-api.io on the MacLustr cluster) | |
| 14 | +# none → heuristic only | |
| 15 | +SRC_LLM_PROVIDER=none | |
| 16 | +SRC_LLM_MODEL=claude-opus-5 | |
| 17 | +# ANTHROPIC_API_KEY= | |
| 18 | +# SRC_LOCAL_LLM_URL=https://www.llm-api.io/v1 | |
| 19 | +# SRC_LOCAL_LLM_KEY= | |
| 20 | +# SRC_LOCAL_LLM_MODEL= | |
| 21 | + | |
| 22 | +# Browser | |
| 23 | +SRC_HEADLESS=false | |
| 24 | +SRC_BROWSER_CHANNEL=chromium | |
| 25 | + | |
| 26 | +# Dashboard | |
| 27 | +SRC_DASHBOARD_PORT=8340 | |
added
.gitignore
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +node_modules/ | |
| 2 | +dist/ | |
| 3 | +.env | |
| 4 | +.env.local | |
| 5 | +*.log | |
| 6 | +.DS_Store | |
| 7 | + | |
| 8 | +# Runtime data: browser profiles hold authenticated sessions — never commit. | |
| 9 | +data/browser_profiles/ | |
| 10 | +data/sessions/ | |
| 11 | +data/media/ | |
| 12 | +data/platform_model/*/ | |
| 13 | +!data/platform_model/.gitkeep | |
| 14 | + | |
| 15 | +# Compiled (learned) connectors are versioned intentionally; regression fixtures may be large. | |
| 16 | +connectors/*/regression_tests/large/ | |
added
CLAUDE.md
+332 −0
@@ -0,0 +1,332 @@ | ||
| 1 | +# CLAUDE.md | |
| 2 | + | |
| 3 | +## Project Name | |
| 4 | + | |
| 5 | +**Social Runtime Crawler** | |
| 6 | + | |
| 7 | +Alternative internal names: SRC · SocialProbe · Social Runtime Mining · Social Browser Intelligence Engine · Living Social Crawler | |
| 8 | + | |
| 9 | +> Repository guide for contributors (humans and agents). Sections 1–87 are the founding specification. | |
| 10 | +> Implementation status, commands and layout: see `README.md` and `docs/ARCHITECTURE.md`. | |
| 11 | +> Repo conventions: pnpm workspaces, strict TypeScript run with `tsx` (no build step), vitest, `@src/*` packages, `pnpm typecheck && pnpm test` before committing. | |
| 12 | +> In-page code (evaluated in the browser) must be plain JavaScript strings or `.page.js` files — TS transpilers inject helpers (`__name`) that do not exist in the page. | |
| 13 | +> Never put credentials in the repo or in `.env`; authentication = human login into the persistent browser profile (`pnpm login <platform>`), profiles live in `data/browser_profiles/` (git-ignored). | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +# 1. Mission | |
| 18 | + | |
| 19 | +Build a new browser-native crawling system specifically designed for modern social-media platforms. | |
| 20 | + | |
| 21 | +This project must NOT behave like a traditional scraper that primarily performs direct HTTP fetches against static pages. | |
| 22 | + | |
| 23 | +Instead, it must treat each social platform as a **live interactive application runtime**. | |
| 24 | + | |
| 25 | +The crawler must: | |
| 26 | + | |
| 27 | +1. launch and maintain authenticated browser sessions; | |
| 28 | +2. navigate social-media interfaces normally; | |
| 29 | +3. observe what the logged-in account is legitimately allowed to see; | |
| 30 | +4. intercept structured information delivered to the browser frontend; | |
| 31 | +5. inspect DOM mutations; | |
| 32 | +6. inspect network responses; | |
| 33 | +7. inspect browser runtime state; | |
| 34 | +8. detect dynamically loaded posts, profiles, videos, comments, pages and recommendations; | |
| 35 | +9. capture media metadata and optionally public media artifacts; | |
| 36 | +10. convert observations into normalized structured events; | |
| 37 | +11. build an internal world model of each social platform; | |
| 38 | +12. use an AI agent to decide what to explore next; | |
| 39 | +13. learn how each platform works during exploration; | |
| 40 | +14. progressively compile learned behavior into reusable platform connectors; | |
| 41 | +15. detect connector breakage and return to exploration mode automatically. | |
| 42 | + | |
| 43 | +The long-term objective is to invent a new crawling paradigm: **Social Runtime Mining**. Instead of downloading documents from the Web, the system observes the execution of social applications and learns how information is surfaced through normal browser interaction. | |
| 44 | + | |
| 45 | +# 2. Core Principle | |
| 46 | + | |
| 47 | +Traditional crawler: `URL → HTTP GET → HTML → Parse → Follow links → Repeat`. | |
| 48 | + | |
| 49 | +Social Runtime Crawler: | |
| 50 | + | |
| 51 | +```text | |
| 52 | +Authenticated Browser → Live Social Application → (DOM | Network | Visual) | |
| 53 | +→ Runtime Observation Layer → Semantic World Model → AI Navigation Agent → Action Selection | |
| 54 | +→ click / scroll / search / open profile / open post / play video / expand comments ↻ | |
| 55 | +``` | |
| 56 | + | |
| 57 | +The crawler should continuously ask: *What action will reveal the most useful new information?* — not *What URL should I download next?* | |
| 58 | + | |
| 59 | +# 3. Scope | |
| 60 | + | |
| 61 | +Initial target platforms: Facebook, Instagram, TikTok, X / Twitter, YouTube, Reddit, LinkedIn, Threads. | |
| 62 | + | |
| 63 | +Prototype order — Phase 1: YouTube, Reddit · Phase 2: Facebook, Instagram · Phase 3: TikTok, X · Phase 4: LinkedIn, Threads. The architecture must nevertheless be platform-independent. | |
| 64 | + | |
| 65 | +# 4. Important Boundary | |
| 66 | + | |
| 67 | +The system must only process information visible to the authenticated account through normal use of the platform. | |
| 68 | + | |
| 69 | +The system must NOT be designed to: bypass authentication; defeat access controls; obtain private messages; access private profiles not visible to the account; discover hidden API credentials; defeat CAPTCHA systems; evade platform security mechanisms; bypass rate limits through deceptive identity rotation; exploit undocumented vulnerabilities; circumvent paywalls or privacy settings; impersonate users without authorization. | |
| 70 | + | |
| 71 | +Authentication state should be supplied manually during development. The crawler may observe and structure data already delivered to the browser. | |
| 72 | + | |
| 73 | +# 5. Primary Use Case | |
| 74 | + | |
| 75 | +A research account is logged into Facebook. The crawler: open Facebook → observe feed → detect visible post → extract post metadata → detect author/page/profile → detect media → detect interactions → detect comments if opened → follow visible public profile → inspect profile → discover other public posts → return to feed → continue. | |
| 76 | + | |
| 77 | +The same architecture must work for TikTok For You, Instagram Explore, YouTube recommendations, Reddit feeds, Facebook pages and public profiles, X timelines, LinkedIn public posts. | |
| 78 | + | |
| 79 | +# 6. Architectural Philosophy | |
| 80 | + | |
| 81 | +Build this as a set of cooperating systems. Never create a monolithic Playwright script. | |
| 82 | + | |
| 83 | +Main systems: Browser Runtime · Observation Layer · Entity Extraction Layer · Media Intelligence Layer · World Model · Navigation Agent · Information Gain Engine · Platform Adapter · Connector Learning Engine · Connector Compiler · Persistence Layer · Scheduler · Replay System · Monitoring · Research UI. | |
| 84 | + | |
| 85 | +# 7. Browser Runtime | |
| 86 | + | |
| 87 | +Node.js / TypeScript, Playwright, Chromium, Chrome DevTools Protocol. Primary controller: Playwright. Lower-level telemetry: CDP. | |
| 88 | + | |
| 89 | +Each account uses a persistent browser profile (`browser_profiles/<platform>-<alias>/`). Never persist passwords manually in project files. Authentication: launch browser → human login → save browser profile → reuse authenticated profile. | |
| 90 | + | |
| 91 | +# 8. Browser Sessions | |
| 92 | + | |
| 93 | +```ts | |
| 94 | +interface SocialBrowserSession { | |
| 95 | + sessionId: string; platform: Platform; profilePath: string; accountAlias: string; | |
| 96 | + start(): Promise<void>; stop(): Promise<void>; navigate(url: string): Promise<void>; getCurrentState(): Promise<PageState>; | |
| 97 | +} | |
| 98 | +``` | |
| 99 | + | |
| 100 | +Track: session_id, platform, account_alias, started_at, current_url, current_entity, navigation_depth, last_action, health. | |
| 101 | + | |
| 102 | +# 9. Observation Surfaces | |
| 103 | + | |
| 104 | +SURFACE_1_NETWORK · SURFACE_2_DOM · SURFACE_3_RUNTIME_STATE · SURFACE_4_ACCESSIBILITY · SURFACE_5_VISUAL · SURFACE_6_MEDIA · SURFACE_7_NAVIGATION. | |
| 105 | + | |
| 106 | +Each observation must contain provenance: `{ "value": "Example Creator", "source": "network", "confidence": 0.99, "observed_at": "..." }`. | |
| 107 | + | |
| 108 | +# 10. Network Observation Layer | |
| 109 | + | |
| 110 | +Observe browser-generated XHR, fetch, GraphQL, JSON, HTML fragments, WebSocket frames, SSE, media manifests, video metadata, image resources. Modules: NetworkObserver, ResponseClassifier, JsonDetector, GraphQLDetector, WebSocketObserver, MediaRequestObserver, SchemaProfiler. | |
| 111 | + | |
| 112 | +Do NOT hardcode endpoints during initial exploration. Fingerprint them: `{ hostname, method, content_type, response_shape_hash, observed_entity_types }`. | |
| 113 | + | |
| 114 | +# 11. Automatic Response Classification | |
| 115 | + | |
| 116 | +response → is JSON? → schema inference → contains repeated objects? → candidate entity detection → field analysis → relationship detection → confidence score. | |
| 117 | + | |
| 118 | +Detect post IDs, profile IDs, usernames, captions, timestamps, engagement counts, comment arrays, cursor values, pagination tokens, media URLs, thumbnail URLs, video manifests, author objects, recommendation metadata. Avoid assumptions about property names; use structure and semantic inference. | |
| 119 | + | |
| 120 | +# 12. Schema Discovery | |
| 121 | + | |
| 122 | +`SchemaProfiler` infers `abc → likely_identifier`, `xyz.foo → likely_display_name`, `xyz.bar → likely_username` from field name, value shape, frequency, co-occurrence, DOM evidence, visual evidence, LLM semantic inference. | |
| 123 | + | |
| 124 | +# 13. DOM Observation | |
| 125 | + | |
| 126 | +MutationObserver inside the page. Track elements added/removed, text changes, attributes, lazy-loaded sections, new feed cards, modal opening, comment expansion, video replacement, infinite-scroll additions. Inject a lightweight observer; emit deltas (`{ "event": "dom_nodes_added", "count": 14, "region": "feed" }`), never serialize the entire DOM continuously. | |
| 127 | + | |
| 128 | +# 14. Semantic DOM Representation | |
| 129 | + | |
| 130 | +Never send the full DOM blindly to an LLM. Generate a compact page representation (PAGE / VISIBLE ENTITIES [1] Page… [2] Post… / ACTIONS [A1]…). The AI agent chooses only semantic actions. | |
| 131 | + | |
| 132 | +# 15. Accessibility Surface | |
| 133 | + | |
| 134 | +Optional observer using accessible roles and labels (buttons, links, menus, tabs, search fields, video controls, follow buttons, profile links, comment controls). Prefer semantic locators over fragile generated CSS classes. | |
| 135 | + | |
| 136 | +# 16. Visual Surface | |
| 137 | + | |
| 138 | +Fallback, not primary. Capture viewport screenshots, content cards, video frames, profile headers, overlays — only when network confidence low, DOM confidence low, content image-based, text embedded in media, UI unknown, or video classification required. | |
| 139 | + | |
| 140 | +# 17. Video Intelligence | |
| 141 | + | |
| 142 | +`src/media/video/`: VideoDetector, VideoMetadataExtractor, VideoFrameSampler, SubtitleExtractor, TranscriptResolver, AudioMetadataExtractor, VideoFingerprint, MediaDeduplicator. Collect platform video ID, author, caption, description, hashtags, duration, thumbnail, public engagement metrics, creation timestamp, visible music/audio name, subtitles, resolution, delivery metadata. | |
| 143 | + | |
| 144 | +# 18. Video Frame Sampling | |
| 145 | + | |
| 146 | +Do NOT download every video. video detected → metadata → relevance estimate → if relevant: capture frames (start, 25 %, 50 %, 75 %, end; adaptive: scene change) → vision analysis → semantic representation. Store perceptual hashes for dedup. | |
| 147 | + | |
| 148 | +# 19. Video Semantic Record | |
| 149 | + | |
| 150 | +Normalized `{ media_type, platform, platform_media_id, author_entity_id, caption, topics[], entities[], language, transcript_available, visual_summary, engagement{views,likes,comments} }`. | |
| 151 | + | |
| 152 | +# 20. Public Profile Modeling | |
| 153 | + | |
| 154 | +Profiles become canonical entities `{ entity_type: "person", canonical_id, display_name, platform_profiles{}, professional_context, public_bio, topics[], sources[] }`. Do not infer sensitive attributes (religion, medical conditions, sexual orientation, private family information, home address, political ideology inferred from behavior) unless scope explicitly changes with a justified basis. | |
| 155 | + | |
| 156 | +# 21. Identity Resolution | |
| 157 | + | |
| 158 | +`IdentityResolver` signals: exact name, username similarity, verified links, official website, cross-linked accounts, same organization, bio similarity, profile photo similarity where appropriate, public external links. Never merge two people solely because names match; every merge requires confidence and evidence. | |
| 159 | + | |
| 160 | +# 22. Entity Types | |
| 161 | + | |
| 162 | +Person, Organization, Page, Profile, Channel, Account, Post, Comment, Video, Image, Topic, Hashtag, URL, Event, Location, Product, OrganizationRole. Relationships: AUTHORED, MENTIONED, REPLIED_TO, POSTED_BY, BELONGS_TO, LINKS_TO, FEATURES, HAS_PROFILE, REPRESENTS, DISCOVERED_FROM. | |
| 163 | + | |
| 164 | +# 23. Social World Model | |
| 165 | + | |
| 166 | +Graph-like in-memory representation of the current platform (Profile A —AUTHORED→ Post 1 …). This graph guides navigation. | |
| 167 | + | |
| 168 | +# 24. Navigation Agent | |
| 169 | + | |
| 170 | +The LLM must NOT manipulate raw selectors. It receives `{ goal, current_state, visible_entities[], available_actions[] }` and answers `{ action, expected_information_gain, reason }`. | |
| 171 | + | |
| 172 | +# 25. Action Vocabulary | |
| 173 | + | |
| 174 | +OPEN_ENTITY, OPEN_POST, OPEN_PROFILE, OPEN_PAGE, OPEN_CHANNEL, OPEN_VIDEO, OPEN_COMMENTS, SCROLL_DOWN, SCROLL_UP, SEARCH, FILTER, PLAY_VIDEO, PAUSE_VIDEO, EXPAND, COLLAPSE, BACK, FORWARD, RETURN_TO_FEED, WAIT_FOR_CONTENT, END_SESSION. Do not expose arbitrary browser JavaScript to the planner. | |
| 175 | + | |
| 176 | +# 26. Information Gain Engine | |
| 177 | + | |
| 178 | +`information_gain = novelty × relevance × expected_entity_yield × confidence × source_quality ÷ exploration_cost`, with penalties: already_seen, duplicate_content, navigation_loop, low_quality_source, low_relevance. | |
| 179 | + | |
| 180 | +# 27. Novelty Measurement | |
| 181 | + | |
| 182 | +Embeddings: novelty ≈ `1 − max_similarity(candidate, existing dataset)`. Use a local embedding model whenever possible. | |
| 183 | + | |
| 184 | +# 28. Agent Modes | |
| 185 | + | |
| 186 | +OBSERVE (feed only; study recommendation exposure) · RESEARCH (search + navigate public entities) · PROFILE (one public person) · TOPIC (a topic → people, organizations, posts, videos, channels, hashtags) · PLATFORM LEARNING (understand UI, runtime, entity structures, schemas, navigation). | |
| 187 | + | |
| 188 | +# 29. Connector Learning | |
| 189 | + | |
| 190 | +Do not immediately hardcode a Facebook connector; learn first. Store `platform_model/{page_types, entity_patterns, response_patterns, action_patterns, navigation_graph, selectors, media_patterns}.json`. | |
| 191 | + | |
| 192 | +# 30. Action → Observation Learning | |
| 193 | + | |
| 194 | +Every action records consequences (`before`/`after`: visible posts, network requests, new entities). Infer over time: `scroll_down → usually FeedResponseType3 → ~8 new posts → exposes cursor`. | |
| 195 | + | |
| 196 | +# 31. Automatic Connector Compilation | |
| 197 | + | |
| 198 | +Once confidence is high: learn platform → compile connector (`connectors/<platform>/manifest, page_classifier, network_patterns, entities, navigation, media, parser, regression_tests/`). The connector is learned state, not manually written truth. | |
| 199 | + | |
| 200 | +# 32. Self-Healing | |
| 201 | + | |
| 202 | +`expected entities = 20, observed = 0` → `CONNECTOR_DEGRADED` → exploration mode → re-inspect DOM/network → re-learn → update connector → regression tests → resume. | |
| 203 | + | |
| 204 | +# 33. Universal Social Event Format | |
| 205 | + | |
| 206 | +`{ event_id, event_type, platform, session_id, timestamp, entity{type, platform_id, canonical_id}, provenance[{surface, confidence}], discovered_via{action} }`. | |
| 207 | + | |
| 208 | +# 34. Event Types | |
| 209 | + | |
| 210 | +PAGE_OPENED · ENTITY_DISCOVERED/OBSERVED/UPDATED · POST_/COMMENT_/PROFILE_DISCOVERED · MEDIA_/VIDEO_/IMAGE_DISCOVERED · NETWORK_RESPONSE_OBSERVED · NETWORK_SCHEMA_DISCOVERED · DOM_CHANGED · ACTION_EXECUTED/FAILED · NAVIGATION_COMPLETED · CONNECTOR_PATTERN_LEARNED/DEGRADED/REPAIRED. | |
| 211 | + | |
| 212 | +# 35. Storage | |
| 213 | + | |
| 214 | +Prototype: PostgreSQL, Redis, local filesystem / S3-compatible object storage. Optional later: ClickHouse, OpenSearch, Qdrant, Neo4j. Do not add all databases prematurely. | |
| 215 | + | |
| 216 | +# 36. PostgreSQL Core Tables | |
| 217 | + | |
| 218 | +sessions, platform_accounts, entities, entity_aliases, observations, posts, media, videos, relationships, actions, network_responses, schema_patterns, connector_versions, crawl_jobs. | |
| 219 | + | |
| 220 | +# 37. Raw vs Normalized Data | |
| 221 | + | |
| 222 | +RAW OBSERVATION → NORMALIZATION → CANONICAL ENTITY. Raw observations must never be overwritten. | |
| 223 | + | |
| 224 | +# 38. Evidence Model | |
| 225 | + | |
| 226 | +Every canonical field needs provenance: `{ field, value, evidence[{observation_id, source}] }`. | |
| 227 | + | |
| 228 | +# 39. Deduplication | |
| 229 | + | |
| 230 | +Same platform ID, canonical URL, media fingerprint, normalized text, high embedding similarity, same author+timestamp+content. Never deduplicate people using fuzzy names alone. | |
| 231 | + | |
| 232 | +# 40. Crawl Budget | |
| 233 | + | |
| 234 | +`budget: { max_minutes, max_actions, max_profiles, max_posts, max_videos, max_depth }` plus max_video_processing_seconds, max_storage_mb, max_llm_tokens. | |
| 235 | + | |
| 236 | +# 41. Navigation Safety | |
| 237 | + | |
| 238 | +Maintain visited_entities, visited_urls, recent_actions, navigation_path, page_fingerprints. Detect loops (A → B → A → B) and break automatically. | |
| 239 | + | |
| 240 | +# 42. Platform Boundary | |
| 241 | + | |
| 242 | +`stay_on_platform = true`: external links may be recorded but not followed automatically. | |
| 243 | + | |
| 244 | +# 43. Search-Based Discovery | |
| 245 | + | |
| 246 | +Research mode supports native platform search: find search field → submit query → classify result tabs → inspect people/pages/posts/videos → select relevant entities. | |
| 247 | + | |
| 248 | +# 44–45. Feed-Based Discovery & Recommendation Dataset | |
| 249 | + | |
| 250 | +Preserve ranking: feed_session_id, feed_position, content_id, timestamp_seen, time_visible, interaction_performed, recommendation_context (`is_followed_account`, `is_sponsored`, `time_visible_ms`). | |
| 251 | + | |
| 252 | +# 46. Interaction Policy | |
| 253 | + | |
| 254 | +Default **read-only**. Allowed: scroll, search, open, expand, play video, navigate. Never automatically like, follow, comment, share, message, react, subscribe unless explicitly enabled for a controlled experiment. | |
| 255 | + | |
| 256 | +# 47. Media Capture Strategy | |
| 257 | + | |
| 258 | +LEVEL 0 metadata · 1 + thumbnail · 2 + selected frames (default) · 3 + transcript/subtitles · 4 full artifact only when required and permitted. | |
| 259 | + | |
| 260 | +# 48–50. Content Classification, Local AI, Model Hierarchy | |
| 261 | + | |
| 262 | +Local LLM/embeddings for topic, language, mentions, names, locations, category, summary, tags — never invent facts. Pipeline: deterministic parser → local embeddings → local classifier → larger LLM only where needed. Tiers: 1 rules/parsers · 2 embeddings · 3 small local LLM · 4 large reasoning model. Escalate only when needed. | |
| 263 | + | |
| 264 | +# 51. Initial Prototype Goal | |
| 265 | + | |
| 266 | +YouTube or Reddit first: start authenticated browser → search topic → detect result entities → navigate results → intercept network → compare network vs DOM extraction → capture posts/videos → normalize events → store in PostgreSQL → dashboard. | |
| 267 | + | |
| 268 | +# 52. Facebook Experimental Prototype | |
| 269 | + | |
| 270 | +Open authenticated Facebook → one known public page → observe → identify posts → intercept runtime traffic → open one post → expand visible comments → identify public profile/page links → open one → capture public info → return. Success: no Facebook-specific external API, no manual data entry, browser stays authenticated, all entities have provenance, actions recorded, video objects identified, schemas discovered automatically. | |
| 271 | + | |
| 272 | +# 53. Debug UI | |
| 273 | + | |
| 274 | +Panels: Browser Preview, Current Page State, Visible Entities, Available Actions, Agent Decision, Network Events, Detected Schemas, World Model, Media Queue, Recent Observations, Connector Confidence. | |
| 275 | + | |
| 276 | +# 54–55. Replay & Explainability | |
| 277 | + | |
| 278 | +Every session replayable from recorded observations (actions, URL changes, network metadata, DOM observations, screenshots, decisions). Each AI action stores goal, chosen action, expected gain, relevance, novelty, concise reason — no hidden chain-of-thought. | |
| 279 | + | |
| 280 | +# 56. Platform Adapter Interface | |
| 281 | + | |
| 282 | +`detectPageType, extractVisibleEntities, getAvailableActions, resolveEntityTarget, detectMedia, detectFeedItems, normalizeObservation`. | |
| 283 | + | |
| 284 | +# 57. Generic Engine vs Platform Logic | |
| 285 | + | |
| 286 | +Generic: browser lifecycle, network capture, DOM observation, event bus, planning, information gain, storage, media pipeline, identity resolution. Platform-specific: page recognition, semantic labels, feed identification, known entity classes, navigation hints, normalization quirks. | |
| 287 | + | |
| 288 | +# 58–60. Page Classification, Confidence, Unknown States | |
| 289 | + | |
| 290 | +Types: HOME_FEED, SEARCH_RESULTS, PROFILE, PUBLIC_PAGE, POST_DETAIL, VIDEO_DETAIL, CHANNEL, GROUP, COMMENT_VIEW, UNKNOWN. Signals: URL, visible text, ARIA landmarks, DOM structure, network schemas, adapter hints. Every inference carries confidence; below threshold → stronger classifier. Unknown pages are expected: capture semantic DOM + network summary → classify → generic actions → learn. | |
| 291 | + | |
| 292 | +# 61–63. Connector Knowledge Base, Shared Learning, Workers | |
| 293 | + | |
| 294 | +Persist learned behaviour (`{pattern, platform, schema_hash, likely_entity_type, confidence, observed_count}`). Workers share patterns through a Connector Knowledge Service → versioned platform model. Worker = Browser + Playwright + CDP + Observer + Media Processor + Action Executor; control plane = Scheduler, Queue, DB, Connector Knowledge, LLM Router, Dashboard. | |
| 295 | + | |
| 296 | +# 64–66. Cluster Deployment, Isolation, Crash Recovery | |
| 297 | + | |
| 298 | +One Mac node per platform worker (MacLustr), central PostgreSQL/Redis/object storage/API/dashboard. One browser profile per account; separate processes. Recover from browser/tab crash, timeouts, missing selectors, session expiration (pause job, `AUTH_REQUIRED`, manual re-auth — never bypass), unexpected modals, redesigns. | |
| 299 | + | |
| 300 | +# 67–69. Observability, Quality Metrics, Experimentation | |
| 301 | + | |
| 302 | +Track actions/min, entities/min, posts/min, videos/min, responses/min, duplicate ratio, LLM calls, average gain, connector confidence, CPU/RAM, storage, errors. KPIs: entity/field precision, recall on visible page, navigation success, duplicate rate, connector stability, media detection accuracy, cost per 1 000 entities, entities per browser-hour. Compare DOM only / Network only / DOM+Network / +Agent / +Vision. | |
| 303 | + | |
| 304 | +# 70–74. Research Question & Innovations | |
| 305 | + | |
| 306 | +Can this outperform traditional social crawling (less connector code, automatic adaptation, dynamic content, recommendation context, media, learned frontend APIs, surviving UI changes)? Innovations: Runtime API Discovery (action → response → entity), Social Runtime Graph (UI states, actions, schemas), Information-Gain Navigation, Connector Compiler (`social-runtime learn facebook` → "Platform learned … Confidence: 94 %"; `social-runtime crawl facebook --goal "Quebec public personalities"`). | |
| 307 | + | |
| 308 | +# 75. Development Phases | |
| 309 | + | |
| 310 | +0 repo · 1 persistent browser · 2 network observer · 3 DOM observer · 4 event normalization · 5 entity extraction · 6 semantic actions · 7 AI planner · 8 information gain · 9 media/video · 10 platform learning · 11 connector compilation · 12 self-healing · 13 distributed workers. | |
| 311 | + | |
| 312 | +# 76–77. Repository Structure & Stack | |
| 313 | + | |
| 314 | +`apps/{api,dashboard,worker}`, `packages/{browser,observers,agent,entities,media,connectors,platform-model,storage,events,shared}`, `connectors/<platform>/`, `data/`, `scripts/`, `docs/`, `docker/`. TypeScript, Node.js, Playwright, Chromium, CDP, PostgreSQL, Redis, React/Next.js dashboard, Docker where appropriate; optional Qdrant, ClickHouse, Neo4j, MinIO. Do not over-engineer the prototype. | |
| 315 | + | |
| 316 | +# 78–81. Coding Principles & Testing | |
| 317 | + | |
| 318 | +Strict TypeScript, small modules, typed events, DI where useful, structured logging, deterministic parsers before LLMs, clear adapters, unit + integration tests, record/replay fixtures. Avoid one giant platform script, hundreds of static CSS selectors, massive switch statements, platform logic inside the core, LLM-controlled arbitrary JS. Fixture-driven tests for schemas, DOM fragments, normalization, identity resolution, action selection, media detection, page classification; browser integration tests for profile reuse, navigation, scroll, search, open entity, interception, mutation capture, media detection. | |
| 319 | + | |
| 320 | +# 82–86. Data Review, Vision, Success | |
| 321 | + | |
| 322 | +Dashboard lets a human inspect raw observation, normalized value, canonical entity, evidence, surface, confidence and correct mappings (feeding learning). Vision: `"Find public Quebec personalities discussing AI"` → system understands platforms, navigates, observes runtime, discovers profiles, captures posts/videos, extracts entities, deduplicates identities, builds graph, returns dataset — without a hand-maintained scraper per page. | |
| 323 | + | |
| 324 | +First milestone succeeds when: one human login is reused; navigation by semantic actions; dynamic content observed; posts/profiles/media recognized; useful network payloads identified; network vs DOM evidence compared; video metadata captured; normalized events stored; next action chosen; loops avoided; at least one reusable platform pattern learned automatically. | |
| 325 | + | |
| 326 | +# 87. Final Engineering Rule | |
| 327 | + | |
| 328 | +When implementing any feature, always ask: *Are we teaching the crawler how the social application works, or merely hardcoding another scraper?* | |
| 329 | + | |
| 330 | +Prefer learning · observation · semantic actions · structured frontend data legitimately delivered to the browser · deterministic extraction over LLM guessing · provenance over assumptions · reusable platform knowledge over one-off automation. | |
| 331 | + | |
| 332 | +The end goal is not to automate Chrome. It is to **build an autonomous system capable of understanding and mining the observable runtime of social applications.** | |
added
README.md
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# Social Runtime Crawler | |
| 2 | + | |
| 3 | +Browser-native, self-learning crawler for modern social platforms. Instead of downloading documents, it **observes the runtime of a social application** through an authenticated browser (Playwright + Chromium), fuses several observation surfaces (network, DOM, media, navigation), builds a world model, lets an information-gain agent decide what to look at next, and progressively **learns how the platform works** (response schemas, page types, action → observation patterns) into a compiled connector. | |
| 4 | + | |
| 5 | +Full specification: [`CLAUDE.md`](./CLAUDE.md). Architecture notes: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). | |
| 6 | + | |
| 7 | +## Status — v0.1 proof of concept (2026-09-11) | |
| 8 | + | |
| 9 | +Implemented (Phases 0 → 8 of §75, plus media detection and platform learning): | |
| 10 | + | |
| 11 | +| Area | What works | | |
| 12 | +|---|---| | |
| 13 | +| Browser runtime | persistent Chromium profile per (platform, account), human-in-the-loop login, CDP session | | |
| 14 | +| Network surface | XHR/fetch/GraphQL/WebSocket/media capture, `SchemaProfiler` (field semantics without property-name assumptions), generic JSON entity miner, endpoint fingerprints | | |
| 15 | +| DOM surface | injected `MutationObserver` deltas by region, in-page semantic snapshot (links, articles, videos, search, expand controls) | | |
| 16 | +| Page classification | URL hints + landmarks + density, confidence, `UNKNOWN` never crashes | | |
| 17 | +| Entities | URL-grammar extraction, network ↔ DOM merge with per-field provenance, agreements/conflicts report, identity-resolution skeleton | | |
| 18 | +| Agent | semantic action vocabulary, information-gain scoring, lexical novelty, loop detector, heuristic planner + optional LLM planner (Anthropic or local OpenAI-compatible) | | |
| 19 | +| Media | video detection (DOM `<video>` + manifests/segments + video entities), frame sampling at LEVEL ≥ 2 | | |
| 20 | +| Learning | `data/platform_model/<platform>/` (response, action, page, media patterns), degradation signal, connector manifest compilation | | |
| 21 | +| Storage | PostgreSQL (raw observations, entities, media, actions, schemas, feed items, relationships) + JSONL replay log | | |
| 22 | +| Dashboard | `pnpm dashboard` → http://localhost:8340 (decisions, page state, entities, schemas, media, connector confidence, world model, raw events) | | |
| 23 | +| Adapters | YouTube, Reddit (Phase 1). Facebook/Instagram/TikTok/X/LinkedIn/Threads: not yet | | |
| 24 | + | |
| 25 | +Read-only by construction: no like/follow/comment/share/subscribe code path exists. | |
| 26 | + | |
| 27 | +## Quick start | |
| 28 | + | |
| 29 | +```bash | |
| 30 | +pnpm install | |
| 31 | +cp .env.example .env # set SRC_DATABASE_URL (createdb social_runtime) — optional | |
| 32 | +pnpm db:migrate # creates the tables | |
| 33 | + | |
| 34 | +pnpm login youtube # opens Chromium, log in by hand, press Enter → profile saved | |
| 35 | +pnpm crawl youtube --query "intelligence artificielle Québec" --goal "Discover public Quebec creators discussing AI" --minutes 10 | |
| 36 | +pnpm dashboard # research console | |
| 37 | + | |
| 38 | +pnpm learn youtube --minutes 15 # PLATFORM LEARNING mode + connector compilation → connectors/youtube/manifest.json | |
| 39 | +pnpm src status youtube # "Platform learned: page types / entity types / schemas / confidence" | |
| 40 | +pnpm replay # list sessions ; pnpm replay <session_id> [--type ACTION_PLANNED] | |
| 41 | +pnpm test && pnpm typecheck | |
| 42 | +``` | |
| 43 | + | |
| 44 | +Flags for `crawl`: `--mode research|observe|topic|profile|learn`, `--seed <url>`, `--minutes`, `--actions`, `--profiles`, `--posts`, `--videos`, `--media 0-4` (capture level, default 1), `--headless`, `--account <alias>`. | |
| 45 | + | |
| 46 | +LLM planner: set `SRC_LLM_PROVIDER=anthropic` (uses `ANTHROPIC_API_KEY` or an `ant auth login` profile, model `claude-opus-5`) or `SRC_LLM_PROVIDER=local` with `SRC_LOCAL_LLM_URL` / `SRC_LOCAL_LLM_MODEL` (e.g. llm-api.io on the cluster). The LLM is only consulted when the heuristic is unsure, only sees the compact page state, and can only pick an existing action id. | |
| 47 | + | |
| 48 | +## Layout | |
| 49 | + | |
| 50 | +``` | |
| 51 | +apps/worker CLI + crawl engine (observe → plan → act → learn loop) + action executor | |
| 52 | +apps/api dashboard server (node:http) + public/index.html | |
| 53 | +packages/shared types (platforms, surfaces, entities, actions, budgets), utils, logger, config | |
| 54 | +packages/events Universal Social Event Format, typed bus, JSONL replay log | |
| 55 | +packages/browser SocialBrowserSession (persistent profile), interactive login | |
| 56 | +packages/observers network (NetworkObserver, ResponseClassifier, SchemaProfiler), dom (DomObserver, PageSummarizer), PageClassifier | |
| 57 | +packages/entities surface merge with provenance, confidence, IdentityResolver | |
| 58 | +packages/media video detection, frame sampler | |
| 59 | +packages/agent WorldModel, InformationGain, LoopDetector, planners, LLM clients | |
| 60 | +packages/platform-model learned platform knowledge + connector compiler | |
| 61 | +packages/storage PostgreSQL store + schema.sql | |
| 62 | +packages/connectors adapter interface, generic DOM→entity extraction, YouTube + Reddit adapters | |
| 63 | +connectors/<platform>/ compiled (learned) manifests — generated, not hand-written truth | |
| 64 | +data/ browser_profiles/ (never commit), sessions/<id>/events.jsonl, platform_model/, media/ | |
| 65 | +``` | |
| 66 | + | |
| 67 | +## Boundaries (§4) | |
| 68 | + | |
| 69 | +Only information the authenticated account can see through normal use. No bypass of authentication, access controls, CAPTCHA, rate limits, private content or privacy settings. Credentials are never typed or stored by the crawler; the browser profile is the only persisted authentication state and lives outside git. | |
added
apps/api/package.json
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/api", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "dependencies": { | |
| 7 | + "@src/shared": "workspace:*", | |
| 8 | + "@src/storage": "workspace:*" | |
| 9 | + } | |
| 10 | +} | |
added
apps/api/public/index.html
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8" /> | |
| 5 | +<title>Social Runtime Crawler — research console</title> | |
| 6 | +<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 7 | +<style> | |
| 8 | + :root { --bg:#0b0d10; --panel:#12161b; --line:#1f262e; --fg:#d7dee6; --dim:#7f8b97; --acc:#4cc2ff; --ok:#41d18d; --warn:#ffb454; --bad:#ff6b6b; } | |
| 9 | + * { box-sizing:border-box; } | |
| 10 | + body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.45 "SF Mono", ui-monospace, Menlo, monospace; } | |
| 11 | + header { display:flex; gap:16px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:2; } | |
| 12 | + header h1 { font-size:14px; margin:0; letter-spacing:.04em; color:var(--acc); } | |
| 13 | + header select, header button { background:var(--panel); color:var(--fg); border:1px solid var(--line); padding:5px 8px; font:inherit; border-radius:4px; } | |
| 14 | + main { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; padding:10px 16px; } | |
| 15 | + section { background:var(--panel); border:1px solid var(--line); border-radius:6px; min-height:120px; display:flex; flex-direction:column; overflow:hidden; } | |
| 16 | + section h2 { margin:0; padding:7px 10px; font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; } | |
| 17 | + section .body { padding:8px 10px; overflow:auto; max-height:420px; white-space:pre-wrap; word-break:break-word; } | |
| 18 | + .c3 { grid-column: span 3; } .c4 { grid-column: span 4; } .c6 { grid-column: span 6; } .c8 { grid-column: span 8; } .c12 { grid-column: span 12; } | |
| 19 | + table { width:100%; border-collapse:collapse; } td, th { padding:3px 6px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; } th { color:var(--dim); font-weight:normal; } | |
| 20 | + .tag { display:inline-block; padding:0 6px; border-radius:3px; background:#1a222b; color:var(--acc); margin-right:4px; font-size:11px; } | |
| 21 | + .ok { color:var(--ok);} .warn { color:var(--warn);} .bad { color:var(--bad);} .dim { color:var(--dim);} | |
| 22 | + .kpi { display:flex; gap:18px; flex-wrap:wrap; } .kpi div b { display:block; font-size:20px; color:var(--fg); } .kpi div { color:var(--dim); } | |
| 23 | + .bar { height:6px; background:#1a222b; border-radius:3px; overflow:hidden; } .bar i { display:block; height:100%; background:var(--acc); } | |
| 24 | + a { color:var(--acc); text-decoration:none; } | |
| 25 | + .frames img { height:70px; margin:2px; border:1px solid var(--line); border-radius:3px; } | |
| 26 | +</style> | |
| 27 | +</head> | |
| 28 | +<body> | |
| 29 | +<header> | |
| 30 | + <h1>SOCIAL RUNTIME CRAWLER</h1> | |
| 31 | + <select id="session"></select> | |
| 32 | + <button id="refresh">refresh</button> | |
| 33 | + <label><input type="checkbox" id="auto" checked /> auto (5 s)</label> | |
| 34 | + <span id="status" class="dim"></span> | |
| 35 | +</header> | |
| 36 | +<main> | |
| 37 | + <section class="c12"><h2>Session <span id="goal" class="dim"></span></h2><div class="body kpi" id="kpi"></div></section> | |
| 38 | + <section class="c6"><h2>Agent decisions <span class="dim">why did it do that?</span></h2><div class="body" id="decisions"></div></section> | |
| 39 | + <section class="c6"><h2>Current page state <span class="dim">semantic DOM</span></h2><div class="body" id="page"></div></section> | |
| 40 | + <section class="c6"><h2>Visible / recent entities <span id="entcount" class="dim"></span></h2><div class="body" id="entities"></div></section> | |
| 41 | + <section class="c6"><h2>Detected network schemas <span class="dim">runtime API discovery</span></h2><div class="body" id="schemas"></div></section> | |
| 42 | + <section class="c4"><h2>Media queue</h2><div class="body" id="media"></div></section> | |
| 43 | + <section class="c4"><h2>Connector confidence <span class="dim">learned platform model</span></h2><div class="body" id="model"></div></section> | |
| 44 | + <section class="c4"><h2>World model</h2><div class="body" id="world"></div></section> | |
| 45 | + <section class="c12"><h2>Recent observations <span class="dim">raw event stream</span></h2><div class="body" id="events"></div></section> | |
| 46 | +</main> | |
| 47 | +<script> | |
| 48 | +const $ = (id) => document.getElementById(id); | |
| 49 | +const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); | |
| 50 | +const api = (p) => fetch("/api" + p).then((r) => r.json()); | |
| 51 | +let current = null; | |
| 52 | + | |
| 53 | +async function loadSessions() { | |
| 54 | + const sessions = await api("/sessions"); | |
| 55 | + const sel = $("session"); | |
| 56 | + const prev = sel.value; | |
| 57 | + sel.innerHTML = sessions.map((s) => `<option value="${esc(s.session_id)}">${esc(s.session_id)} · ${esc(s.platform)} · ${esc(s.mode ?? "")} · ${esc(s.health ?? "")}</option>`).join(""); | |
| 58 | + if (prev && sessions.some((s) => s.session_id === prev)) sel.value = prev; | |
| 59 | + current = sel.value || null; | |
| 60 | +} | |
| 61 | + | |
| 62 | +async function render() { | |
| 63 | + if (!current) return; | |
| 64 | + $("status").textContent = "loading…"; | |
| 65 | + const [summary, actions, entities, schemas, media, events, world] = await Promise.all([ | |
| 66 | + api(`/sessions/${current}/summary`), api(`/sessions/${current}/actions`), api(`/sessions/${current}/entities`), | |
| 67 | + api(`/sessions/${current}/schemas`), api(`/sessions/${current}/media`), api(`/sessions/${current}/events?limit=120`), api(`/sessions/${current}/world`), | |
| 68 | + ]); | |
| 69 | + const job = summary.job ?? {}; const sum = summary.summary; | |
| 70 | + $("goal").textContent = `${job.platform ?? ""} · ${job.mode ?? ""} · ${job.goal ?? ""}`; | |
| 71 | + const pages = events.filter((e) => e.event_type === "PAGE_OPENED"); | |
| 72 | + const last = pages[0]; | |
| 73 | + $("kpi").innerHTML = [ | |
| 74 | + ["steps", actions.length], ["entities", entities.length], ["schemas", schemas.length], ["media", media.length], | |
| 75 | + ["events", events.length + (events.length >= 120 ? "+" : "")], ["status", sum ? `ended: ${sum.ended_because}` : "running"], | |
| 76 | + ["planner", actions[actions.length - 1]?.planner ?? "—"], | |
| 77 | + ].map(([k, v]) => `<div><b>${esc(v)}</b>${k}</div>`).join(""); | |
| 78 | + | |
| 79 | + $("decisions").innerHTML = `<table><tr><th>#</th><th>action</th><th>gain</th><th>nov</th><th>rel</th><th>planner</th><th>reason</th></tr>` + | |
| 80 | + actions.slice().reverse().slice(0, 40).map((a) => { | |
| 81 | + const act = a.action ?? { type: a.action_type, label: a.label }; | |
| 82 | + const gain = a.expected_information_gain ?? a.expected_gain ?? 0; | |
| 83 | + return `<tr><td>${a.step}</td><td><span class="tag">${esc(act.type)}</span>${esc((act.label || "").slice(0, 70))}</td><td>${Number(gain).toFixed(3)}</td><td>${Number(a.novelty ?? 0).toFixed(2)}</td><td>${Number(a.relevance ?? 0).toFixed(2)}</td><td>${esc(a.planner)}</td><td class="dim">${esc(a.reason)}</td></tr>`; | |
| 84 | + }).join("") + `</table>`; | |
| 85 | + | |
| 86 | + if (last) { | |
| 87 | + const p = last.payload; | |
| 88 | + $("page").innerHTML = `<div><span class="tag">${esc(p.page_type)}</span> conf ${Number(p.confidence).toFixed(2)} · <a href="${esc(p.url)}" target="_blank">${esc((p.url || "").slice(0, 90))}</a></div> | |
| 89 | + <div class="dim">entities ${p.entities} · dom ${p.dom_entities} · network ${p.network_entities} · both ${p.both_surfaces} · field agreements ${p.field_agreements} · conflicts ${(p.field_conflicts || []).length} · videos ${p.videos}</div> | |
| 90 | + <div class="dim">signals: ${esc((p.signals || []).join(", "))}</div><hr style="border-color:var(--line)"/>${esc(p.summary)}`; | |
| 91 | + } else $("page").textContent = "no page yet"; | |
| 92 | + | |
| 93 | + $("entcount").textContent = `${entities.length}`; | |
| 94 | + $("entities").innerHTML = `<table><tr><th>type</th><th>name</th><th>author</th><th>metrics</th><th>sources</th><th>conf</th></tr>` + | |
| 95 | + entities.slice(0, 80).map((e) => { | |
| 96 | + const prov = e.provenance || (e.fields ? Object.values(e.fields).flatMap((f) => f.provenance || []) : []); | |
| 97 | + const surfaces = [...new Set(prov.map((p) => p.surface))]; | |
| 98 | + const conf = e.confidence ?? Math.max(0, ...prov.map((p) => p.confidence)); | |
| 99 | + return `<tr><td><span class="tag">${esc(e.type ?? e.entity_type)}</span></td><td>${e.url ? `<a href="${esc(e.url)}" target="_blank">` : ""}${esc((e.name || e.text || e.text_excerpt || e.platform_id || "").slice(0, 80))}${e.url ? "</a>" : ""}</td><td class="dim">${esc((e.author || "").slice(0, 30))}</td><td class="dim">${esc(JSON.stringify(e.metrics || {}))}</td><td>${surfaces.map((s) => `<span class="tag">${esc(s)}</span>`).join("")}</td><td>${Number(conf).toFixed(2)}</td></tr>`; | |
| 100 | + }).join("") + `</table>`; | |
| 101 | + | |
| 102 | + $("schemas").innerHTML = `<table><tr><th>endpoint</th><th>shape</th><th>entities</th><th>seen</th></tr>` + | |
| 103 | + schemas.slice(0, 60).map((s) => { | |
| 104 | + const fp = s.fingerprint || {}; const sc = s.schema || {}; | |
| 105 | + const types = (sc.candidate_entity_types || []).map((c) => `${c.type} ${Number(c.confidence).toFixed(2)}`).join(", "); | |
| 106 | + return `<tr><td>${esc(fp.method)} ${esc(fp.hostname)}<span class="dim">${esc(fp.path_pattern)}</span>${fp.graphql_operation ? ` <span class="tag">${esc(fp.graphql_operation)}</span>` : ""}</td><td class="dim">${esc((s.shape_hash || fp.response_shape_hash || "").slice(0, 10))}</td><td>${esc(types)}</td><td>${esc(s.observed_count ?? "")}</td></tr>`; | |
| 107 | + }).join("") + `</table>`; | |
| 108 | + | |
| 109 | + $("media").innerHTML = media.length ? media.slice(0, 30).map((m) => `<div><span class="tag">${esc(m.media_type)}</span>${esc((m.title || m.platform_media_id || "").slice(0, 60))} <span class="dim">${m.duration_s ? Math.round(m.duration_s) + "s" : ""} ${m.width ? m.width + "×" + m.height : ""} ${m.delivery?.kind ?? ""}</span>${(m.frames || []).length ? `<div class="frames">${m.frames.map((f) => `<img src="/media/${esc(f.split("/media/")[1] || "")}" />`).join("")}</div>` : ""}</div>`).join("") : "<span class='dim'>no media yet</span>"; | |
| 110 | + | |
| 111 | + if (job.platform) { | |
| 112 | + const pm = await api(`/platform-model/${job.platform}`); | |
| 113 | + if (pm && pm.response_patterns) { | |
| 114 | + const rps = Object.values(pm.response_patterns).filter((r) => Object.keys(r.likely_entity_types || {}).length).sort((a, b) => b.confidence - a.confidence); | |
| 115 | + const conf = rps.length ? rps.reduce((s, r) => s + r.confidence, 0) / rps.length : 0; | |
| 116 | + $("model").innerHTML = `<div class="kpi"><div><b>${Math.round(conf * 100)}%</b>schema confidence</div><div><b>${Object.keys(pm.page_types || {}).length}</b>page types</div><div><b>${rps.length}</b>entity schemas</div><div><b>${(pm.sessions_learned || []).length}</b>sessions</div></div> | |
| 117 | + ${rps.slice(0, 12).map((r) => `<div style="margin-top:6px"><span class="dim">${esc(r.hostname)}${esc(r.path_pattern)}</span> ${Object.keys(r.likely_entity_types).map((t) => `<span class="tag">${esc(t)}</span>`).join("")}<div class="bar"><i style="width:${Math.round(r.confidence * 100)}%"></i></div></div>`).join("")}`; | |
| 118 | + } else $("model").textContent = "no learned model yet"; | |
| 119 | + } | |
| 120 | + | |
| 121 | + const nodes = world.nodes || []; const byType = {}; | |
| 122 | + for (const n of nodes) byType[n.type] = (byType[n.type] || 0) + 1; | |
| 123 | + $("world").innerHTML = `<div class="kpi"><div><b>${nodes.length}</b>nodes</div><div><b>${(world.edges || []).length}</b>edges</div><div><b>${nodes.filter((n) => n.visited).length}</b>visited</div></div> | |
| 124 | + <div style="margin-top:8px">${Object.entries(byType).map(([t, n]) => `<span class="tag">${esc(t)} ${n}</span>`).join(" ")}</div> | |
| 125 | + <div class="dim" style="margin-top:8px">${nodes.filter((n) => (n.surfaces || []).length > 1).length} nodes confirmed by ≥2 surfaces</div>`; | |
| 126 | + | |
| 127 | + $("events").innerHTML = `<table>` + events.slice(0, 120).map((e) => { | |
| 128 | + const p = e.payload || {}; let brief = ""; | |
| 129 | + if (e.event_type === "NETWORK_RESPONSE_OBSERVED") brief = `${p.kind} ${p.hostname}${p.path_pattern} · ${p.entity_count} entities · ${p.body_size} B`; | |
| 130 | + else if (e.event_type === "DOM_CHANGED") brief = `+${p.added} −${p.removed} ${JSON.stringify(p.regions)}${p.modal_opened ? " modal" : ""}`; | |
| 131 | + else if (/DISCOVERED|OBSERVED/.test(e.event_type) && p.entity) brief = `${p.entity.type}: ${(p.entity.name || p.entity.platform_id || "").slice(0, 70)}`; | |
| 132 | + else if (e.event_type === "ACTION_PLANNED") brief = `${p.action?.type} · ${p.reason}`; | |
| 133 | + else brief = JSON.stringify(p).slice(0, 140); | |
| 134 | + const cls = /FAILED|DEGRADED|LOOP|AUTH|ERROR/.test(e.event_type) ? "bad" : /LEARNED|REPAIRED|SCHEMA/.test(e.event_type) ? "ok" : ""; | |
| 135 | + return `<tr><td class="dim">${esc((e.ts || e.timestamp || "").slice(11, 19))}</td><td>#${e.step ?? 0}</td><td class="${cls}">${esc(e.event_type)}</td><td class="dim">${esc(brief)}</td></tr>`; | |
| 136 | + }).join("") + `</table>`; | |
| 137 | + $("status").textContent = `updated ${new Date().toLocaleTimeString()}`; | |
| 138 | +} | |
| 139 | + | |
| 140 | +$("session").addEventListener("change", () => { current = $("session").value; render(); }); | |
| 141 | +$("refresh").addEventListener("click", () => loadSessions().then(render)); | |
| 142 | +setInterval(() => { if ($("auto").checked) loadSessions().then(render); }, 5000); | |
| 143 | +loadSessions().then(render); | |
| 144 | +</script> | |
| 145 | +</body> | |
| 146 | +</html> | |
added
apps/api/src/server.ts
+109 −0
@@ -0,0 +1,109 @@ | ||
| 1 | +import http from "node:http"; | |
| 2 | +import fs from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { fileURLToPath } from "node:url"; | |
| 5 | +import { createLogger, loadConfig } from "@src/shared"; | |
| 6 | +import { JsonlEventLog } from "@src/events"; | |
| 7 | +import { PostgresStore } from "@src/storage"; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Research/Debug dashboard API (§53, §82). Zero framework: node:http + a static page. | |
| 11 | + * Reads PostgreSQL when configured, else the JSONL session logs. | |
| 12 | + */ | |
| 13 | +const log = createLogger("dashboard"); | |
| 14 | +const cfg = loadConfig(); | |
| 15 | +const here = path.dirname(fileURLToPath(import.meta.url)); | |
| 16 | +const publicDir = path.join(here, "..", "public"); | |
| 17 | +let store: PostgresStore | undefined; | |
| 18 | +try { | |
| 19 | + if (cfg.databaseUrl) store = await PostgresStore.connect(cfg.databaseUrl); | |
| 20 | +} catch (err) { | |
| 21 | + log.warn("no database — serving JSONL logs", { err: (err as Error).message }); | |
| 22 | +} | |
| 23 | + | |
| 24 | +function json(res: http.ServerResponse, data: unknown, status = 200) { | |
| 25 | + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "access-control-allow-origin": "*" }); | |
| 26 | + res.end(JSON.stringify(data)); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function sessionDirs(): string[] { | |
| 30 | + return fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).filter((d) => fs.existsSync(path.join(cfg.sessionsDir, d, "events.jsonl"))).sort().reverse() : []; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function jsonlSessions() { | |
| 34 | + return sessionDirs().map((id) => { | |
| 35 | + const job = readJson(path.join(cfg.sessionsDir, id, "job.json")) as Record<string, unknown> | undefined; | |
| 36 | + const summary = readJson(path.join(cfg.sessionsDir, id, "summary.json")) as Record<string, unknown> | undefined; | |
| 37 | + return { session_id: id, platform: job?.platform, mode: job?.mode, goal: job?.goal, account_alias: job?.account_alias, started_at: fs.statSync(path.join(cfg.sessionsDir, id)).birthtime, health: summary ? "stopped" : "running", actions: summary?.steps ?? null, entities: summary?.entities ?? null, ended_because: summary?.ended_because }; | |
| 38 | + }); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function readJson(file: string): unknown { | |
| 42 | + try { | |
| 43 | + return JSON.parse(fs.readFileSync(file, "utf8")); | |
| 44 | + } catch { | |
| 45 | + return undefined; | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +const server = http.createServer(async (req, res) => { | |
| 50 | + const url = new URL(req.url ?? "/", "http://localhost"); | |
| 51 | + const parts = url.pathname.split("/").filter(Boolean); | |
| 52 | + try { | |
| 53 | + if (parts[0] === "api") { | |
| 54 | + if (parts[1] === "sessions" && !parts[2]) return json(res, store ? await store.listSessions() : jsonlSessions()); | |
| 55 | + if (parts[1] === "sessions" && parts[2]) { | |
| 56 | + const id = parts[2]; | |
| 57 | + const dir = path.join(cfg.sessionsDir, id); | |
| 58 | + const sub = parts[3]; | |
| 59 | + if (sub === "events") { | |
| 60 | + const types = url.searchParams.get("types")?.split(",").filter(Boolean); | |
| 61 | + const limit = Number(url.searchParams.get("limit") ?? 300); | |
| 62 | + if (store) return json(res, await store.sessionEvents(id, { types, limit })); | |
| 63 | + const evs = JsonlEventLog.read(dir).filter((e) => !types?.length || types.includes(e.event_type)); | |
| 64 | + return json(res, evs.slice(-limit).reverse()); | |
| 65 | + } | |
| 66 | + if (sub === "actions") return json(res, store ? await store.sessionActions(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "ACTION_PLANNED").map((e) => ({ step: e.step, ...(e.payload as Record<string, unknown>) }))); | |
| 67 | + if (sub === "entities") { | |
| 68 | + if (store) return json(res, await store.sessionEntities(id)); | |
| 69 | + const seen = new Map<string, unknown>(); | |
| 70 | + for (const e of JsonlEventLog.read(dir)) if (/DISCOVERED|ENTITY_OBSERVED/.test(e.event_type) && (e.payload as { entity?: { fingerprint: string } }).entity) seen.set((e.payload as { entity: { fingerprint: string } }).entity.fingerprint, { ...(e.payload as { entity: object }).entity, last_step: e.step, provenance: e.provenance }); | |
| 71 | + return json(res, [...seen.values()].reverse()); | |
| 72 | + } | |
| 73 | + if (sub === "media") return json(res, store ? await store.sessionMedia(id) : JsonlEventLog.read(dir).filter((e) => e.event_type === "MEDIA_DISCOVERED").map((e) => e.payload)); | |
| 74 | + if (sub === "world") return json(res, readJson(path.join(dir, "world_model.json")) ?? { nodes: [], edges: [] }); | |
| 75 | + if (sub === "summary") return json(res, { job: readJson(path.join(dir, "job.json")), summary: readJson(path.join(dir, "summary.json")) }); | |
| 76 | + if (sub === "schemas") { | |
| 77 | + const platform = (readJson(path.join(dir, "job.json")) as { platform?: string } | undefined)?.platform; | |
| 78 | + if (store) return json(res, await store.schemas(platform)); | |
| 79 | + return json(res, JsonlEventLog.read(dir).filter((e) => e.event_type === "NETWORK_SCHEMA_DISCOVERED").map((e) => e.payload)); | |
| 80 | + } | |
| 81 | + } | |
| 82 | + if (parts[1] === "platform-model" && parts[2]) { | |
| 83 | + const f = path.join(cfg.platformModelDir, parts[2], "platform_model.json"); | |
| 84 | + return json(res, readJson(f) ?? { error: "no model yet" }); | |
| 85 | + } | |
| 86 | + if (parts[1] === "entity" && parts[2]) return json(res, store ? await store.entity(decodeURIComponent(parts[2])) : { error: "needs database" }); | |
| 87 | + if (parts[1] === "stats") return json(res, store ? await store.entityStats() : []); | |
| 88 | + return json(res, { error: "not found" }, 404); | |
| 89 | + } | |
| 90 | + if (parts[0] === "media") { | |
| 91 | + // serve sampled frames | |
| 92 | + const file = path.join(cfg.mediaDir, ...parts.slice(1)); | |
| 93 | + if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404); | |
| 94 | + res.writeHead(200, { "content-type": "image/jpeg" }); | |
| 95 | + fs.createReadStream(file).pipe(res); | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html"); | |
| 99 | + if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404); | |
| 100 | + const ext = path.extname(file); | |
| 101 | + res.writeHead(200, { "content-type": ext === ".html" ? "text/html; charset=utf-8" : ext === ".js" ? "text/javascript" : ext === ".css" ? "text/css" : "application/octet-stream" }); | |
| 102 | + fs.createReadStream(file).pipe(res); | |
| 103 | + } catch (err) { | |
| 104 | + log.error("request failed", { url: req.url, err: (err as Error).message }); | |
| 105 | + json(res, { error: (err as Error).message }, 500); | |
| 106 | + } | |
| 107 | +}); | |
| 108 | + | |
| 109 | +server.listen(cfg.dashboardPort, () => log.info(`dashboard → http://localhost:${cfg.dashboardPort} (${store ? "postgres" : "jsonl"})`)); | |
added
apps/worker/package.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/worker", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "dependencies": { | |
| 7 | + "@src/shared": "workspace:*", | |
| 8 | + "@src/events": "workspace:*", | |
| 9 | + "@src/browser": "workspace:*", | |
| 10 | + "@src/observers": "workspace:*", | |
| 11 | + "@src/entities": "workspace:*", | |
| 12 | + "@src/media": "workspace:*", | |
| 13 | + "@src/agent": "workspace:*", | |
| 14 | + "@src/platform-model": "workspace:*", | |
| 15 | + "@src/storage": "workspace:*", | |
| 16 | + "@src/connectors": "workspace:*", | |
| 17 | + "playwright": "^1.63.0" | |
| 18 | + } | |
| 19 | +} | |
added
apps/worker/src/cli.ts
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { DEFAULT_BUDGET, isPlatform, loadConfig, newId, type AgentMode, type CrawlJob, type MediaLevel, type Platform } from "@src/shared"; | |
| 4 | +import { interactiveLogin } from "@src/browser"; | |
| 5 | +import { JsonlEventLog } from "@src/events"; | |
| 6 | +import { PlatformModel, compileConnector } from "@src/platform-model"; | |
| 7 | +import { CrawlEngine } from "./engine.ts"; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * social-runtime CLI (§74): | |
| 11 | + * pnpm src login <platform> [--account alias] | |
| 12 | + * pnpm src crawl <platform> --goal "…" [--mode research|observe|topic|profile|learn] [--query "…"] [--seed url] [--minutes 10] [--actions 60] [--media 2] [--headless] | |
| 13 | + * pnpm src learn <platform> [--minutes 15] (PLATFORM LEARNING MODE + connector compilation) | |
| 14 | + * pnpm src replay <session_id> [--type EVENT_TYPE] (inspect why the crawler did what it did) | |
| 15 | + * pnpm src status <platform> (learned model summary) | |
| 16 | + */ | |
| 17 | +const [, , cmd, ...rest] = process.argv; | |
| 18 | +const args = parseArgs(rest); | |
| 19 | +const cfg = loadConfig(); | |
| 20 | + | |
| 21 | +function parseArgs(list: string[]): { positional: string[]; flags: Record<string, string | boolean> } { | |
| 22 | + const positional: string[] = []; | |
| 23 | + const flags: Record<string, string | boolean> = {}; | |
| 24 | + for (let i = 0; i < list.length; i++) { | |
| 25 | + const a = list[i]!; | |
| 26 | + if (a.startsWith("--")) { | |
| 27 | + const key = a.slice(2); | |
| 28 | + const next = list[i + 1]; | |
| 29 | + if (next !== undefined && !next.startsWith("--")) { | |
| 30 | + flags[key] = next; | |
| 31 | + i++; | |
| 32 | + } else flags[key] = true; | |
| 33 | + } else positional.push(a); | |
| 34 | + } | |
| 35 | + return { positional, flags }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +function platformArg(): Platform { | |
| 39 | + const p = args.positional[0]; | |
| 40 | + if (!p || !isPlatform(p)) { | |
| 41 | + console.error(`Usage: ${cmd} <platform> — one of youtube, reddit, facebook, instagram, tiktok, x, linkedin, threads`); | |
| 42 | + process.exit(2); | |
| 43 | + } | |
| 44 | + return p; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function str(k: string, d?: string): string | undefined { | |
| 48 | + const v = args.flags[k]; | |
| 49 | + return typeof v === "string" ? v : d; | |
| 50 | +} | |
| 51 | +function num(k: string, d: number): number { | |
| 52 | + const v = args.flags[k]; | |
| 53 | + return typeof v === "string" && !Number.isNaN(Number(v)) ? Number(v) : d; | |
| 54 | +} | |
| 55 | + | |
| 56 | +async function main() { | |
| 57 | + switch (cmd) { | |
| 58 | + case "login": { | |
| 59 | + const platform = platformArg(); | |
| 60 | + await interactiveLogin({ platform, accountAlias: str("account", "research-01")!, profilesDir: cfg.profilesDir, channel: cfg.browserChannel }); | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + case "crawl": | |
| 64 | + case "learn": { | |
| 65 | + const platform = platformArg(); | |
| 66 | + const isLearn = cmd === "learn"; | |
| 67 | + const mode = (str("mode", isLearn ? "learn" : "research") as AgentMode) ?? "research"; | |
| 68 | + const query = str("query") ?? str("q"); | |
| 69 | + const goal = str("goal") ?? (isLearn ? `Learn how ${platform} surfaces content: page types, entity structures, response schemas, navigation` : query ? `Discover public content about “${query}”` : `Observe the ${platform} feed`); | |
| 70 | + if (args.flags.headless) cfg.headless = true; | |
| 71 | + const job: CrawlJob = { | |
| 72 | + job_id: newId("job"), | |
| 73 | + platform, | |
| 74 | + account_alias: str("account", "research-01")!, | |
| 75 | + mode, | |
| 76 | + goal, | |
| 77 | + query, | |
| 78 | + seed_url: str("seed"), | |
| 79 | + budget: { ...DEFAULT_BUDGET, max_minutes: num("minutes", isLearn ? 15 : DEFAULT_BUDGET.max_minutes), max_actions: num("actions", isLearn ? 80 : DEFAULT_BUDGET.max_actions), max_profiles: num("profiles", DEFAULT_BUDGET.max_profiles), max_posts: num("posts", DEFAULT_BUDGET.max_posts), max_videos: num("videos", DEFAULT_BUDGET.max_videos), max_depth: num("depth", DEFAULT_BUDGET.max_depth) }, | |
| 80 | + media_level: Math.max(0, Math.min(4, num("media", 1))) as MediaLevel, | |
| 81 | + stay_on_platform: true, | |
| 82 | + read_only: true, | |
| 83 | + }; | |
| 84 | + const engine = new CrawlEngine(cfg, job); | |
| 85 | + const summary = await engine.run(); | |
| 86 | + printSummary(summary); | |
| 87 | + return; | |
| 88 | + } | |
| 89 | + case "status": { | |
| 90 | + const platform = platformArg(); | |
| 91 | + const model = new PlatformModel(platform, cfg.platformModelDir); | |
| 92 | + const s = model.summary(); | |
| 93 | + console.log(renderLearned(s)); | |
| 94 | + if (args.flags.compile) console.log("compiled →", compileConnector(model, path.join(process.cwd(), "connectors", platform))); | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + case "replay": { | |
| 98 | + const id = args.positional[0]; | |
| 99 | + if (!id) { | |
| 100 | + const dirs = fs.existsSync(cfg.sessionsDir) ? fs.readdirSync(cfg.sessionsDir).sort() : []; | |
| 101 | + console.log("sessions:\n" + dirs.map((d) => " " + d).join("\n")); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + const events = JsonlEventLog.read(path.join(cfg.sessionsDir, id)); | |
| 105 | + const type = str("type"); | |
| 106 | + for (const ev of events) { | |
| 107 | + if (type && ev.event_type !== type) continue; | |
| 108 | + if (["ACTION_PLANNED", "ACTION_EXECUTED", "ACTION_FAILED", "PAGE_OPENED", "NETWORK_SCHEMA_DISCOVERED", "CONNECTOR_PATTERN_LEARNED", "CONNECTOR_DEGRADED", "LOOP_DETECTED", "AUTH_REQUIRED", "BUDGET_EXHAUSTED", "MEDIA_DISCOVERED"].includes(ev.event_type) || type) { | |
| 109 | + const p = ev.payload as Record<string, unknown>; | |
| 110 | + const brief = ev.event_type === "ACTION_PLANNED" ? `${(p.action as { type: string; label: string }).type} — ${(p.action as { label: string }).label} | gain=${Number(p.expected_information_gain).toFixed(3)} | ${p.planner} | ${p.reason}` : ev.event_type === "PAGE_OPENED" ? `${p.page_type} (${Number(p.confidence ?? 0).toFixed(2)}) ${p.url} · entities=${p.entities} dom=${p.dom_entities} net=${p.network_entities} both=${p.both_surfaces}` : ev.event_type === "NETWORK_SCHEMA_DISCOVERED" ? `${(p.fingerprint as { hostname: string; path_pattern: string }).hostname}${(p.fingerprint as { path_pattern: string }).path_pattern} → ${JSON.stringify((p.schema as { candidate_entity_types: unknown }).candidate_entity_types)}` : JSON.stringify(p).slice(0, 220); | |
| 111 | + console.log(`${ev.timestamp.slice(11, 19)} #${ev.step ?? 0} ${ev.event_type.padEnd(26)} ${brief}`); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + default: | |
| 117 | + console.log(`social-runtime — Social Runtime Crawler | |
| 118 | + | |
| 119 | + pnpm src login <platform> [--account alias] | |
| 120 | + pnpm src crawl <platform> --goal "…" [--query "…"] [--mode research|observe|topic|profile|learn] [--seed url] [--minutes 10] [--actions 60] [--media 0-4] [--headless] | |
| 121 | + pnpm src learn <platform> [--minutes 15] | |
| 122 | + pnpm src status <platform> [--compile] | |
| 123 | + pnpm src replay [session_id] [--type EVENT_TYPE] | |
| 124 | + pnpm dashboard → http://localhost:${cfg.dashboardPort} | |
| 125 | +`); | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +function printSummary(s: Awaited<ReturnType<CrawlEngine["run"]>>) { | |
| 130 | + console.log(` | |
| 131 | +Session ${s.session_id} (${s.platform}) — ended: ${s.ended_because} | |
| 132 | + steps: ${s.steps} entities: ${s.entities} videos: ${s.videos} | |
| 133 | + network responses: ${s.network_responses} distinct schemas: ${s.schemas} patterns learned: ${s.patterns_learned} | |
| 134 | + world: ${JSON.stringify(s.world.by_type)} multi-surface: ${s.world.multi_surface} | |
| 135 | +${renderLearned(s.connector)} | |
| 136 | + log: ${s.session_dir}/events.jsonl | |
| 137 | +`); | |
| 138 | +} | |
| 139 | + | |
| 140 | +function renderLearned(s: ReturnType<PlatformModel["summary"]>): string { | |
| 141 | + return `Platform ${s.platform} learned (${s.sessions} session${s.sessions === 1 ? "" : "s"}) | |
| 142 | + Page types: ${s.page_types} | |
| 143 | + Entity types: ${s.entity_types} | |
| 144 | + Navigation actions: ${s.navigation_actions} | |
| 145 | + Network schemas: ${s.network_schemas} | |
| 146 | + Media patterns: ${s.media_patterns} | |
| 147 | + Confidence: ${s.confidence}%`; | |
| 148 | +} | |
| 149 | + | |
| 150 | +main().catch((err) => { | |
| 151 | + console.error(err); | |
| 152 | + process.exit(1); | |
| 153 | +}); | |
added
apps/worker/src/engine.ts
+320 −0
@@ -0,0 +1,320 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { | |
| 4 | + createLogger, | |
| 5 | + nowIso, | |
| 6 | + truncate, | |
| 7 | + type AgentDecision, | |
| 8 | + type AppConfig, | |
| 9 | + type CrawlJob, | |
| 10 | + type ObservedEntity, | |
| 11 | + type PageState, | |
| 12 | + type Platform, | |
| 13 | +} from "@src/shared"; | |
| 14 | +import { EventBus, JsonlEventLog } from "@src/events"; | |
| 15 | +import { SocialBrowserSession } from "@src/browser"; | |
| 16 | +import { NetworkObserver, DomObserver, snapshotDom, classifyPage, type ClassifiedResponse } from "@src/observers"; | |
| 17 | +import { mergeSurfaces } from "@src/entities"; | |
| 18 | +import { detectVideos, sampleVideoFrames } from "@src/media"; | |
| 19 | +import { WorldModel, LoopDetector, HeuristicPlanner, LlmPlanner, createLlmClient, type Planner, type GainContext } from "@src/agent"; | |
| 20 | +import { PlatformModel, compileConnector } from "@src/platform-model"; | |
| 21 | +import { openStore, type PostgresStore } from "@src/storage"; | |
| 22 | +import { getAdapter, entitiesFromDom, buildActions, pageFingerprint, type SocialPlatformAdapter } from "@src/connectors"; | |
| 23 | +import { ActionExecutor } from "./executor.ts"; | |
| 24 | + | |
| 25 | +const log = createLogger("engine"); | |
| 26 | + | |
| 27 | +export interface CrawlSummary { | |
| 28 | + session_id: string; | |
| 29 | + platform: Platform; | |
| 30 | + steps: number; | |
| 31 | + entities: number; | |
| 32 | + videos: number; | |
| 33 | + network_responses: number; | |
| 34 | + schemas: number; | |
| 35 | + patterns_learned: number; | |
| 36 | + ended_because: string; | |
| 37 | + world: ReturnType<WorldModel["stats"]>; | |
| 38 | + connector: ReturnType<PlatformModel["summary"]>; | |
| 39 | + session_dir: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * The crawl loop (§2): observe → summarize → plan → act → learn, until the budget or the goal ends it. | |
| 44 | + * Every subsystem is a separate package; this file only orchestrates. | |
| 45 | + */ | |
| 46 | +export class CrawlEngine { | |
| 47 | + private readonly bus = new EventBus(); | |
| 48 | + private readonly world = new WorldModel(); | |
| 49 | + private readonly loops = new LoopDetector(); | |
| 50 | + private readonly adapter: SocialPlatformAdapter; | |
| 51 | + private readonly model: PlatformModel; | |
| 52 | + private session!: SocialBrowserSession; | |
| 53 | + private net!: NetworkObserver; | |
| 54 | + private dom!: DomObserver; | |
| 55 | + private store?: PostgresStore; | |
| 56 | + private eventLog!: JsonlEventLog; | |
| 57 | + private planner!: Planner; | |
| 58 | + private executor!: ActionExecutor; | |
| 59 | + private sessionDir!: string; | |
| 60 | + private step = 0; | |
| 61 | + private stepsWithoutNew = 0; | |
| 62 | + private counts = { entities: 0, videos: 0, profiles: 0, posts: 0, videos_seen: 0, profiles_seen: 0, posts_seen: 0, patterns: 0, schemas: 0 }; | |
| 63 | + private degraded = false; | |
| 64 | + | |
| 65 | + constructor(private readonly cfg: AppConfig, private readonly job: CrawlJob) { | |
| 66 | + this.adapter = getAdapter(job.platform); | |
| 67 | + this.model = new PlatformModel(job.platform, cfg.platformModelDir); | |
| 68 | + } | |
| 69 | + | |
| 70 | + async run(): Promise<CrawlSummary> { | |
| 71 | + const { job, cfg } = this; | |
| 72 | + this.session = new SocialBrowserSession({ platform: job.platform, accountAlias: job.account_alias, profilesDir: cfg.profilesDir, headless: cfg.headless, channel: cfg.browserChannel, bus: this.bus }); | |
| 73 | + this.sessionDir = path.join(cfg.sessionsDir, this.session.id); | |
| 74 | + this.eventLog = new JsonlEventLog(this.sessionDir); | |
| 75 | + this.eventLog.attach(this.bus); | |
| 76 | + this.store = await openStore(cfg.databaseUrl); | |
| 77 | + this.store?.attach(this.bus); | |
| 78 | + fs.writeFileSync(path.join(this.sessionDir, "job.json"), JSON.stringify({ ...job, session_id: this.session.id }, null, 2)); | |
| 79 | + | |
| 80 | + if (!this.session.hasProfile()) log.warn(`No saved browser profile for ${job.platform}/${job.account_alias}. Run: pnpm login ${job.platform} --account ${job.account_alias}`); | |
| 81 | + | |
| 82 | + const llm = createLlmClient(cfg); | |
| 83 | + this.planner = llm ? new LlmPlanner(llm, new HeuristicPlanner(), job.budget.max_llm_tokens) : new HeuristicPlanner(); | |
| 84 | + log.info("planner", { planner: this.planner.name, mode: job.mode, goal: job.goal }); | |
| 85 | + | |
| 86 | + await this.session.start(); | |
| 87 | + // SESSION_STARTED is emitted by the session; enrich the DB row with mode/goal. | |
| 88 | + this.bus.emit({ event_type: "PAGE_OPENED", platform: job.platform, session_id: this.session.id, step: 0, payload: { url: "about:blank", mode: job.mode, goal: job.goal, budget: job.budget } }); | |
| 89 | + const page = this.session.getPage(); | |
| 90 | + this.net = new NetworkObserver({ page, bus: this.bus, sessionId: this.session.id, platform: job.platform, hints: this.adapter.classifierHints }); | |
| 91 | + this.net.attach(); | |
| 92 | + this.dom = new DomObserver({ page, bus: this.bus, sessionId: this.session.id, platform: job.platform }); | |
| 93 | + await this.dom.attach(); | |
| 94 | + this.executor = new ActionExecutor(page, this.adapter, { stayOnPlatform: job.stay_on_platform }); | |
| 95 | + | |
| 96 | + const started = Date.now(); | |
| 97 | + let endedBecause = "unknown"; | |
| 98 | + try { | |
| 99 | + // Seed navigation | |
| 100 | + this.net.beginStep(0, "seed"); | |
| 101 | + this.dom.beginStep(0); | |
| 102 | + const seed = job.seed_url ?? (job.query && job.mode !== "observe" ? this.adapter.searchUrl(job.query) : this.adapter.homeUrl); | |
| 103 | + await this.session.navigate(seed); | |
| 104 | + await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {}); | |
| 105 | + await this.adapter.dismissOverlays?.(page).catch(() => {}); | |
| 106 | + let state = await this.observe(await this.net.collectStep(), "seed"); | |
| 107 | + if (state.classification.page_type === "LOGIN") { | |
| 108 | + this.session.setHealth("auth_required"); | |
| 109 | + this.bus.emit({ event_type: "AUTH_REQUIRED", platform: job.platform, session_id: this.session.id, step: 0, payload: { url: state.url, hint: `pnpm login ${job.platform} --account ${job.account_alias}` } }); | |
| 110 | + endedBecause = "auth_required"; | |
| 111 | + return this.finish(endedBecause, started); | |
| 112 | + } | |
| 113 | + | |
| 114 | + while (true) { | |
| 115 | + const elapsedMin = (Date.now() - started) / 60_000; | |
| 116 | + const b = job.budget; | |
| 117 | + if (this.step >= b.max_actions) { endedBecause = "max_actions"; break; } | |
| 118 | + if (elapsedMin >= b.max_minutes) { endedBecause = "max_minutes"; break; } | |
| 119 | + if (this.counts.profiles >= b.max_profiles) { endedBecause = "max_profiles"; break; } | |
| 120 | + if (this.counts.posts >= b.max_posts) { endedBecause = "max_posts"; break; } | |
| 121 | + if (this.counts.videos >= b.max_videos) { endedBecause = "max_videos"; break; } | |
| 122 | + if (this.session.getInfo().health === "crashed") { endedBecause = "browser_crashed"; break; } | |
| 123 | + | |
| 124 | + this.step++; | |
| 125 | + const decision = await this.plan(state); | |
| 126 | + this.bus.emit({ event_type: "ACTION_PLANNED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { action: decision.chosen_action, expected_information_gain: decision.expected_information_gain, novelty: decision.novelty, relevance: decision.relevance, reason: decision.reason, planner: decision.planner, top_scores: decision.scores.slice(0, 5) } }); | |
| 127 | + log.info(`step ${this.step}: ${decision.chosen_action.type} — ${truncate(decision.chosen_action.label, 80)}`, { gain: decision.expected_information_gain.toFixed(3), planner: decision.planner, reason: truncate(decision.reason, 100) }); | |
| 128 | + if (decision.chosen_action.type === "END_SESSION") { endedBecause = "agent_ended"; break; } | |
| 129 | + | |
| 130 | + const before = { url: state.url, page_type: state.classification.page_type, visible_entities: state.entities.length, world_nodes: this.world.nodes.size }; | |
| 131 | + this.net.beginStep(this.step, decision.chosen_action.id); | |
| 132 | + this.dom.beginStep(this.step); | |
| 133 | + const target = decision.chosen_action.target_ref ? state.entities.find((e) => e.ref === decision.chosen_action.target_ref) : undefined; | |
| 134 | + const result = await this.executor.execute(decision.chosen_action, { entityUrl: target?.url }); | |
| 135 | + this.session.recordAction(decision.chosen_action.type, result.navigated && /^OPEN_/.test(decision.chosen_action.type) ? 1 : decision.chosen_action.type === "BACK" ? -1 : 0); | |
| 136 | + if (target) this.world.markVisited(target.fingerprint, target.url); | |
| 137 | + if (result.navigated) this.bus.emit({ event_type: "NAVIGATION_COMPLETED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { from: result.url_before, to: result.url_after, action: decision.chosen_action.type, duration_ms: result.duration_ms } }); | |
| 138 | + | |
| 139 | + const responses = await this.net.collectStep(); | |
| 140 | + const domDeltas = this.dom.collectStep(); | |
| 141 | + const prevState = state; | |
| 142 | + state = await this.observe(responses, decision.chosen_action.id, target?.fingerprint); | |
| 143 | + const newEntities = this.world.observe(state.entities, this.step, target?.fingerprint); | |
| 144 | + this.stepsWithoutNew = newEntities.length ? 0 : this.stepsWithoutNew + 1; | |
| 145 | + this.tally(newEntities); | |
| 146 | + if (result.ok && result.navigated) this.tallyVisit(decision.chosen_action.type); | |
| 147 | + | |
| 148 | + const after = { url: state.url, page_type: state.classification.page_type, visible_entities: state.entities.length, new_entities: newEntities.length, network_responses: responses.length, dom_nodes_added: domDeltas.reduce((s, d) => s + d.added, 0), world_nodes: this.world.nodes.size }; | |
| 149 | + this.bus.emit({ event_type: result.ok ? "ACTION_EXECUTED" : "ACTION_FAILED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { action: decision.chosen_action, ok: result.ok, error: result.error, before, after, duration_ms: result.duration_ms }, discovered_via: { action_id: decision.chosen_action.id } }); | |
| 150 | + await this.store?.recordAction(this.session.id, decision, before, after, result.ok, result.error, result.duration_ms).catch((e) => log.debug("recordAction failed", { err: (e as Error).message })); | |
| 151 | + await this.persistStep(state, newEntities, responses); | |
| 152 | + | |
| 153 | + // Learning (§30, §71) | |
| 154 | + const outcome = this.model.learnStep({ step: this.step, action_type: decision.chosen_action.type, action_id: decision.chosen_action.id, from_page_type: prevState.classification.page_type, to_page_type: state.classification.page_type, to_url: state.url, page_confidence: state.classification.confidence, entities_total: state.entities.length, new_entities: newEntities.length, entity_types: countTypes(state.entities), responses: responses.map((r) => ({ fingerprint: r.fingerprint, entity_count: r.entities.length, kind: r.kind, url: r.response.url, fields: r.schema?.fields.filter((f) => f.semantic[0]?.kind !== "unknown").slice(0, 60).map((f) => ({ path: f.path, semantic: f.semantic[0], example: f.examples[0] })) })), failed: !result.ok }, this.session.id); | |
| 155 | + if (outcome.newPatterns.length) { | |
| 156 | + this.counts.patterns += outcome.newPatterns.length; | |
| 157 | + this.model.announce(this.bus, this.session.id, this.step, outcome.newPatterns); | |
| 158 | + } | |
| 159 | + this.model.save(); | |
| 160 | + | |
| 161 | + // Degradation / self-healing signal (§32) | |
| 162 | + const degradedNow = this.model.isDegraded(state.classification.page_type, state.entities.length, this.adapter.expectedEntities(state.classification.page_type)); | |
| 163 | + if (degradedNow && !this.degraded) { | |
| 164 | + this.degraded = true; | |
| 165 | + this.bus.emit({ event_type: "CONNECTOR_DEGRADED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { page_type: state.classification.page_type, expected: this.adapter.expectedEntities(state.classification.page_type), observed: 0, url: state.url, action: "switching to exploration heuristics" } }); | |
| 166 | + } else if (!degradedNow && this.degraded && state.entities.length > 0) { | |
| 167 | + this.degraded = false; | |
| 168 | + this.bus.emit({ event_type: "CONNECTOR_REPAIRED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { page_type: state.classification.page_type, observed: state.entities.length } }); | |
| 169 | + } | |
| 170 | + | |
| 171 | + // Loop safety (§41) | |
| 172 | + this.loops.record(state.fingerprint, `${decision.chosen_action.type}:${decision.chosen_action.target_url ?? ""}`); | |
| 173 | + const loop = this.loops.detect(); | |
| 174 | + if (loop.loop) { | |
| 175 | + this.bus.emit({ event_type: "LOOP_DETECTED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { pattern: loop.pattern, url: state.url } }); | |
| 176 | + log.warn("navigation loop detected — breaking out", { pattern: loop.pattern }); | |
| 177 | + await this.executor.execute({ id: "loopbreak", type: "RETURN_TO_FEED", label: "loop break", cost: 1, target_url: job.query ? this.adapter.searchUrl(job.query) : this.adapter.homeUrl }, {}); | |
| 178 | + state = await this.observe(await this.net.collectStep(), "loopbreak"); | |
| 179 | + } | |
| 180 | + if (state.classification.page_type === "LOGIN") { | |
| 181 | + this.session.setHealth("auth_required"); | |
| 182 | + this.bus.emit({ event_type: "AUTH_REQUIRED", platform: job.platform, session_id: this.session.id, step: this.step, payload: { url: state.url } }); | |
| 183 | + endedBecause = "auth_required"; | |
| 184 | + break; | |
| 185 | + } | |
| 186 | + if (this.stepsWithoutNew >= 10) { endedBecause = "no_new_information"; break; } | |
| 187 | + } | |
| 188 | + } catch (err) { | |
| 189 | + endedBecause = `error: ${(err as Error).message.split("\n")[0]}`; | |
| 190 | + log.error("engine error", { err: (err as Error).stack?.split("\n").slice(0, 3).join(" | ") }); | |
| 191 | + this.bus.emit({ event_type: "WORKER_ERROR", platform: job.platform, session_id: this.session.id, step: this.step, payload: { error: (err as Error).message } }); | |
| 192 | + } | |
| 193 | + return this.finish(endedBecause, started); | |
| 194 | + } | |
| 195 | + | |
| 196 | + /** Observe the current page across surfaces and build the compact PageState (§9, §14). */ | |
| 197 | + private async observe(responses: ClassifiedResponse[], via: string, discoveredFrom?: string): Promise<PageState> { | |
| 198 | + const page = this.session.getPage(); | |
| 199 | + const snapshot = await snapshotDom(page).catch((err: Error) => { | |
| 200 | + log.warn("dom snapshot failed", { err: err.message.split("\n")[0] }); | |
| 201 | + return undefined; | |
| 202 | + }); | |
| 203 | + if (!snapshot) { | |
| 204 | + return { url: page.url(), title: "", platform: this.job.platform, classification: { page_type: "UNKNOWN", confidence: 0.1, signals: ["snapshot failed"] }, entities: [], actions: [{ id: "A1", type: "WAIT_FOR_CONTENT", label: "Wait for content", cost: 1 }, { id: "A2", type: "RETURN_TO_FEED", label: "Return to feed", cost: 2, target_url: this.adapter.homeUrl }, { id: "A3", type: "END_SESSION", label: "End", cost: 0.5 }], media: [], summary_text: "", fingerprint: pageFingerprint(page.url(), []), captured_at: nowIso() }; | |
| 205 | + } | |
| 206 | + const classification = classifyPage(snapshot, this.adapter.pageTypeHints); | |
| 207 | + const domEntities = entitiesFromDom(snapshot, this.adapter); | |
| 208 | + const netEntities = responses.flatMap((r) => r.entities); | |
| 209 | + const merged = mergeSurfaces(netEntities, domEntities); | |
| 210 | + // Keep the planner's list focused: DOM-visible entities first, then network-only ones that have a page URL. | |
| 211 | + const entities = merged.merged.filter((e) => e.provenance.some((p) => p.surface === "dom") || e.url).slice(0, 80); | |
| 212 | + const media = detectVideos({ platform: this.job.platform, snapshot, entities, responses, pageUrl: snapshot.url }); | |
| 213 | + const visited = new Set([...this.world.nodes.values()].filter((n) => n.visited).map((n) => n.fingerprint)); | |
| 214 | + const actions = buildActions(entities, snapshot, this.adapter, { query: this.job.query, visited }); | |
| 215 | + const summary = renderSummary(snapshot.title, classification.page_type, entities, actions); | |
| 216 | + const state: PageState = { url: snapshot.url, title: snapshot.title, platform: this.job.platform, classification, entities, actions, media, summary_text: summary, fingerprint: pageFingerprint(snapshot.url, entities), captured_at: nowIso() }; | |
| 217 | + | |
| 218 | + this.bus.emit({ event_type: "PAGE_OPENED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { url: snapshot.url, title: snapshot.title, page_type: classification.page_type, confidence: classification.confidence, signals: classification.signals, entities: entities.length, dom_entities: domEntities.length, network_entities: netEntities.length, both_surfaces: merged.both, field_agreements: merged.field_agreements, field_conflicts: merged.field_conflicts.slice(0, 5), videos: media.length, actions: actions.length, summary }, provenance: [{ surface: "dom", confidence: classification.confidence }], discovered_via: { action_id: via } }); | |
| 219 | + this.bus.emit({ event_type: "PAGE_CLASSIFIED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { url: snapshot.url, ...classification } }); | |
| 220 | + for (const e of entities) { | |
| 221 | + const type = e.type === "video" ? "VIDEO_DISCOVERED" : e.type === "post" ? "POST_DISCOVERED" : e.type === "comment" ? "COMMENT_DISCOVERED" : e.type === "profile" || e.type === "channel" || e.type === "person" ? "PROFILE_DISCOVERED" : "ENTITY_OBSERVED"; | |
| 222 | + const known = this.world.nodes.has(e.fingerprint); | |
| 223 | + this.bus.emit({ event_type: known ? "ENTITY_OBSERVED" : type, platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { entity: { type: e.type, platform_id: e.platform_id, url: e.url, name: e.name, text: e.text, author: e.author, metrics: e.metrics, media: e.media, context: e.context, fingerprint: e.fingerprint, canonical_id: null }, fields: e.fields }, provenance: e.provenance, discovered_via: { action_id: via, url: snapshot.url } }); | |
| 224 | + } | |
| 225 | + for (const m of media) if (m.width || m.duration_s || m.delivery) this.bus.emit({ event_type: "MEDIA_DISCOVERED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { ...m } as Record<string, unknown>, provenance: m.provenance, discovered_via: { action_id: via } }); | |
| 226 | + if (this.step === 0) this.world.observe(entities, 0, discoveredFrom); | |
| 227 | + return state; | |
| 228 | + } | |
| 229 | + | |
| 230 | + private async plan(state: PageState): Promise<AgentDecision> { | |
| 231 | + // Relevance is measured against the goal *and* the topic query (the query carries the domain words). | |
| 232 | + const goal = this.job.query ? `${this.job.goal} ${this.job.query}` : this.job.goal; | |
| 233 | + const ctx: GainContext = { goal, mode: this.job.mode, world: this.world, state, recentActionTypes: this.loops.recentActionTypes(), recentUrls: [...this.world.visitedUrls].slice(-10), stepsWithoutNewEntities: this.stepsWithoutNew, learnedYield: this.model.learnedYield() }; | |
| 234 | + return this.planner.plan(ctx, this.step); | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** Discovery counters (what we have seen). */ | |
| 238 | + private tally(fresh: ObservedEntity[]): void { | |
| 239 | + for (const e of fresh) { | |
| 240 | + this.counts.entities++; | |
| 241 | + if (e.type === "video") this.counts.videos_seen++; | |
| 242 | + else if (e.type === "post" || e.type === "comment") this.counts.posts_seen++; | |
| 243 | + else if (e.type === "profile" || e.type === "channel" || e.type === "person" || e.type === "page") this.counts.profiles_seen++; | |
| 244 | + } | |
| 245 | + } | |
| 246 | + | |
| 247 | + /** Budget counters (§40) count pages we actually opened, not entities merely seen in a feed. */ | |
| 248 | + private tallyVisit(actionType: string): void { | |
| 249 | + if (/^OPEN_(CHANNEL|PROFILE|PAGE)$/.test(actionType)) this.counts.profiles++; | |
| 250 | + else if (/^OPEN_(POST|COMMENTS|ENTITY)$/.test(actionType)) this.counts.posts++; | |
| 251 | + else if (actionType === "OPEN_VIDEO") this.counts.videos++; | |
| 252 | + } | |
| 253 | + | |
| 254 | + private async persistStep(state: PageState, fresh: ObservedEntity[], responses: ClassifiedResponse[]): Promise<void> { | |
| 255 | + const ts = nowIso(); | |
| 256 | + const frames: Record<string, string[]> = {}; | |
| 257 | + if (this.job.media_level >= 2) { | |
| 258 | + const current = state.media.find((m) => m.width || m.delivery?.hostnames.length); | |
| 259 | + if (current) { | |
| 260 | + const files = await sampleVideoFrames(this.session.getPage(), current, this.cfg.mediaDir, this.job.media_level).catch(() => []); | |
| 261 | + if (files.length) { | |
| 262 | + frames[current.fingerprint] = files; | |
| 263 | + this.bus.emit({ event_type: "VIDEO_DISCOVERED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { fingerprint: current.fingerprint, title: current.title, frames: files, level: this.job.media_level }, provenance: [{ surface: "visual", confidence: 0.8 }] }); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + if (!this.store) return; | |
| 268 | + try { | |
| 269 | + await this.store.upsertEntities(state.entities, this.session.id, this.step, ts); | |
| 270 | + await this.store.upsertMedia(state.media, frames, ts); | |
| 271 | + await this.store.recordFeedItems(this.session.id, this.step, state, ts); | |
| 272 | + const recentEdges = this.world.edges.filter((e) => e.step === this.step); | |
| 273 | + await this.store.recordRelationships(this.session.id, recentEdges); | |
| 274 | + } catch (err) { | |
| 275 | + log.debug("persist failed", { err: (err as Error).message, fresh: fresh.length, responses: responses.length }); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + private async finish(endedBecause: string, started: number): Promise<CrawlSummary> { | |
| 280 | + this.model.save(); | |
| 281 | + const connector = this.model.summary(); | |
| 282 | + if (connector.confidence >= 40) { | |
| 283 | + const manifest = compileConnector(this.model, path.join(process.cwd(), "connectors", this.job.platform)); | |
| 284 | + await this.store?.recordConnectorVersion(this.job.platform, connector, manifest).catch(() => {}); | |
| 285 | + } | |
| 286 | + const world = this.world.stats(); | |
| 287 | + fs.writeFileSync(path.join(this.sessionDir, "world_model.json"), JSON.stringify(this.world.toJSON(), null, 2)); | |
| 288 | + const summary: CrawlSummary = { session_id: this.session.id, platform: this.job.platform, steps: this.step, entities: this.world.nodes.size, videos: this.counts.videos_seen, network_responses: this.net?.totalResponses ?? 0, schemas: this.net?.seenShapeHashes.size ?? 0, patterns_learned: this.counts.patterns, ended_because: endedBecause, world, connector, session_dir: this.sessionDir }; | |
| 289 | + this.bus.emit({ event_type: "BUDGET_EXHAUSTED", platform: this.job.platform, session_id: this.session.id, step: this.step, payload: { reason: endedBecause, elapsed_ms: Date.now() - started } }); | |
| 290 | + await this.session.stop(); | |
| 291 | + await this.bus.flush(); | |
| 292 | + fs.writeFileSync(path.join(this.sessionDir, "summary.json"), JSON.stringify(summary, null, 2)); | |
| 293 | + await this.eventLog.close(); | |
| 294 | + await this.store?.close(); | |
| 295 | + return summary; | |
| 296 | + } | |
| 297 | +} | |
| 298 | + | |
| 299 | +function countTypes(entities: ObservedEntity[]): Record<string, number> { | |
| 300 | + const out: Record<string, number> = {}; | |
| 301 | + for (const e of entities) out[e.type] = (out[e.type] ?? 0) + 1; | |
| 302 | + return out; | |
| 303 | +} | |
| 304 | + | |
| 305 | +/** Textual semantic page representation (§14) — what a planner (or a human in the dashboard) reads. */ | |
| 306 | +export function renderSummary(title: string, pageType: string, entities: ObservedEntity[], actions: PageState["actions"]): string { | |
| 307 | + const lines = [`PAGE: ${pageType} — ${truncate(title, 90)}`, "", "VISIBLE ENTITIES"]; | |
| 308 | + for (const e of entities.slice(0, 25)) { | |
| 309 | + lines.push(`[${e.ref}] ${e.type}${e.context ? ` (${e.context})` : ""}`); | |
| 310 | + if (e.name) lines.push(` Name: ${truncate(e.name, 100)}`); | |
| 311 | + if (e.author) lines.push(` Author: ${truncate(e.author, 60)}`); | |
| 312 | + if (e.media?.has_video) lines.push(` Video: yes${e.media.duration_s ? ` (${Math.round(e.media.duration_s)}s)` : ""}`); | |
| 313 | + if (e.metrics && Object.keys(e.metrics).length) lines.push(` Metrics: ${Object.entries(e.metrics).map(([k, v]) => `${k}=${v}`).join(", ")}`); | |
| 314 | + lines.push(` Sources: ${[...new Set(e.provenance.map((p) => p.surface))].join("+")}`); | |
| 315 | + } | |
| 316 | + if (entities.length > 25) lines.push(`… ${entities.length - 25} more`); | |
| 317 | + lines.push("", "ACTIONS"); | |
| 318 | + for (const a of actions) lines.push(`[${a.id}] ${a.label}`); | |
| 319 | + return lines.join("\n"); | |
| 320 | +} | |
added
apps/worker/src/executor.ts
+0 −0
Binary file not shown.
added
connectors/youtube/manifest.json
+165 −0
@@ -0,0 +1,165 @@ | ||
| 1 | +{ | |
| 2 | + "platform": "youtube", | |
| 3 | + "compiled_at": "2026-09-11T21:32:58.338Z", | |
| 4 | + "learned_from_sessions": 4, | |
| 5 | + "confidence": 45, | |
| 6 | + "page_types": [ | |
| 7 | + { | |
| 8 | + "page_type": "UNKNOWN", | |
| 9 | + "url_patterns": [ | |
| 10 | + "/", | |
| 11 | + "/results" | |
| 12 | + ], | |
| 13 | + "avg_entities": 0 | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "page_type": "CHANNEL", | |
| 17 | + "url_patterns": [ | |
| 18 | + "/@Mila-Quebec-AI-Institute" | |
| 19 | + ], | |
| 20 | + "avg_entities": 16 | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "page_type": "SEARCH_RESULTS", | |
| 24 | + "url_patterns": [ | |
| 25 | + "/results" | |
| 26 | + ], | |
| 27 | + "avg_entities": 29 | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "page_type": "VIDEO_DETAIL", | |
| 31 | + "url_patterns": [ | |
| 32 | + "/watch" | |
| 33 | + ], | |
| 34 | + "avg_entities": 39 | |
| 35 | + } | |
| 36 | + ], | |
| 37 | + "network_patterns": [], | |
| 38 | + "navigation": [ | |
| 39 | + { | |
| 40 | + "action": "WAIT_FOR_CONTENT", | |
| 41 | + "from": "UNKNOWN", | |
| 42 | + "to": { | |
| 43 | + "UNKNOWN": 5 | |
| 44 | + }, | |
| 45 | + "avg_new_entities": 0, | |
| 46 | + "observed": 5 | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "action": "RETURN_TO_FEED", | |
| 50 | + "from": "UNKNOWN", | |
| 51 | + "to": { | |
| 52 | + "UNKNOWN": 2 | |
| 53 | + }, | |
| 54 | + "avg_new_entities": 0, | |
| 55 | + "observed": 2 | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + "action": "OPEN_CHANNEL", | |
| 59 | + "from": "SEARCH_RESULTS", | |
| 60 | + "to": { | |
| 61 | + "CHANNEL": 1 | |
| 62 | + }, | |
| 63 | + "avg_new_entities": 14, | |
| 64 | + "observed": 1 | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + "action": "SEARCH", | |
| 68 | + "from": "CHANNEL", | |
| 69 | + "to": { | |
| 70 | + "SEARCH_RESULTS": 1 | |
| 71 | + }, | |
| 72 | + "avg_new_entities": 1, | |
| 73 | + "observed": 1 | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "action": "OPEN_VIDEO", | |
| 77 | + "from": "SEARCH_RESULTS", | |
| 78 | + "to": { | |
| 79 | + "VIDEO_DETAIL": 3 | |
| 80 | + }, | |
| 81 | + "avg_new_entities": 25.7, | |
| 82 | + "observed": 3 | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + "action": "OPEN_VIDEO", | |
| 86 | + "from": "VIDEO_DETAIL", | |
| 87 | + "to": { | |
| 88 | + "VIDEO_DETAIL": 10 | |
| 89 | + }, | |
| 90 | + "avg_new_entities": 29.3, | |
| 91 | + "observed": 10 | |
| 92 | + }, | |
| 93 | + { | |
| 94 | + "action": "SEARCH", | |
| 95 | + "from": "VIDEO_DETAIL", | |
| 96 | + "to": { | |
| 97 | + "SEARCH_RESULTS": 1 | |
| 98 | + }, | |
| 99 | + "avg_new_entities": 1, | |
| 100 | + "observed": 1 | |
| 101 | + } | |
| 102 | + ], | |
| 103 | + "media": [ | |
| 104 | + { | |
| 105 | + "hostname": "www.youtube.com", | |
| 106 | + "kind": "media_segment", | |
| 107 | + "observed_count": 12 | |
| 108 | + }, | |
| 109 | + { | |
| 110 | + "hostname": "rr4---sn-q4fl6nde.googlevideo.com", | |
| 111 | + "kind": "media_segment", | |
| 112 | + "observed_count": 6 | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + "hostname": "i.ytimg.com", | |
| 116 | + "kind": "image", | |
| 117 | + "observed_count": 172 | |
| 118 | + }, | |
| 119 | + { | |
| 120 | + "hostname": "rr5---sn-q4fl6n6z.googlevideo.com", | |
| 121 | + "kind": "media_segment", | |
| 122 | + "observed_count": 6 | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + "hostname": "rr1---sn-cxaaj5o5q5-t0ar.googlevideo.com", | |
| 126 | + "kind": "media_segment", | |
| 127 | + "observed_count": 11 | |
| 128 | + }, | |
| 129 | + { | |
| 130 | + "hostname": "rr2---sn-cxaaj5o5q5-t0ay.googlevideo.com", | |
| 131 | + "kind": "media_segment", | |
| 132 | + "observed_count": 2 | |
| 133 | + }, | |
| 134 | + { | |
| 135 | + "hostname": "rr3---sn-cxaaj5o5q5-t0ay.googlevideo.com", | |
| 136 | + "kind": "media_segment", | |
| 137 | + "observed_count": 1 | |
| 138 | + }, | |
| 139 | + { | |
| 140 | + "hostname": "rr1---sn-cxaaj5o5q5-t0a6.googlevideo.com", | |
| 141 | + "kind": "media_segment", | |
| 142 | + "observed_count": 1 | |
| 143 | + }, | |
| 144 | + { | |
| 145 | + "hostname": "rr5---sn-cxaaj5o5q5-t0ay.googlevideo.com", | |
| 146 | + "kind": "media_segment", | |
| 147 | + "observed_count": 1 | |
| 148 | + }, | |
| 149 | + { | |
| 150 | + "hostname": "rr4---sn-cxaaj5o5q5-t0ay.googlevideo.com", | |
| 151 | + "kind": "media_segment", | |
| 152 | + "observed_count": 2 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "hostname": "rr1---sn-cxaaj5o5q5-t0ay.googlevideo.com", | |
| 156 | + "kind": "media_segment", | |
| 157 | + "observed_count": 3 | |
| 158 | + }, | |
| 159 | + { | |
| 160 | + "hostname": "rr1---sn-cxaaj5o5q5-t0ad.googlevideo.com", | |
| 161 | + "kind": "media_segment", | |
| 162 | + "observed_count": 1 | |
| 163 | + } | |
| 164 | + ] | |
| 165 | +} | |
| \ No newline at end of file | ||
added
data/platform_model/.gitkeep
+0 −0
added
docs/ARCHITECTURE.md
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +# Architecture notes — v0.1 | |
| 2 | + | |
| 3 | +## The loop (apps/worker/src/engine.ts) | |
| 4 | + | |
| 5 | +``` | |
| 6 | +seed (search url / home) NetworkObserver.beginStep / DomObserver.beginStep | |
| 7 | + ↓ | |
| 8 | +observe() = snapshotDom → classifyPage → entitiesFromDom ⊕ mineEntities(network) → mergeSurfaces → detectVideos → buildActions → PageState | |
| 9 | + ↓ | |
| 10 | +plan() = scoreActions (information gain) → HeuristicPlanner | LlmPlanner (only when unsure) | |
| 11 | + ↓ | |
| 12 | +execute() = ActionExecutor (click link / scroll / search / expand / play / back …) ← the only place actions touch the browser | |
| 13 | + ↓ | |
| 14 | +collect network + DOM deltas of the step → observe() again → WorldModel.observe (fresh entities) | |
| 15 | + ↓ | |
| 16 | +PlatformModel.learnStep (action → responses → entities) → CONNECTOR_PATTERN_LEARNED / CONNECTOR_DEGRADED | |
| 17 | + ↓ | |
| 18 | +LoopDetector, budget, AUTH_REQUIRED checks → next step | |
| 19 | +``` | |
| 20 | + | |
| 21 | +Everything is published on the `EventBus` (Universal Social Event Format) and consumed by the JSONL log (replay) and the PostgreSQL store. | |
| 22 | + | |
| 23 | +## Surfaces and provenance | |
| 24 | + | |
| 25 | +Every `ObservedEntity` carries `provenance[]` and `fields{name → {value, provenance[]}}`. The merge (`packages/entities`) keeps both network and DOM evidence, counts field agreements and conflicts, and boosts confidence when two surfaces agree. `PAGE_OPENED` events record `dom_entities / network_entities / both_surfaces / field_agreements` so the "DOM vs network" experiment (§69) is measurable from the logs. | |
| 26 | + | |
| 27 | +## Generic vs platform-specific | |
| 28 | + | |
| 29 | +Generic: browser lifecycle, network capture, `SchemaProfiler`, JSON entity miner, DOM snapshot, page classifier, planner, information gain, world model, learning, storage, dashboard. | |
| 30 | + | |
| 31 | +Adapter (`packages/connectors/<platform>.ts`): hosts, home/search URL, URL grammar → entity type + id, page-type URL hints, id key names, rich-text collapsers (`runs`/`simpleText` on YouTube), noise-link filter, expected entity counts per page type, overlay dismissal. | |
| 32 | + | |
| 33 | +Adapters contain **no endpoints and no CSS classes**. The network layer fingerprints endpoints by `(hostname, path pattern, method, response shape hash)`. | |
| 34 | + | |
| 35 | +## Learned state | |
| 36 | + | |
| 37 | +`data/platform_model/<platform>/platform_model.json` (plus split files): response patterns (shape hash → likely entity types, confidence, triggered_by action), action patterns (avg new entities, target page types, usual shapes), page types (url patterns, avg entities), media patterns. `compileConnector()` writes `connectors/<platform>/manifest.json` when confidence ≥ 40 %. | |
| 38 | + | |
| 39 | +`PlatformModel.learnedYield()` feeds the expected-entity-yield term of the information-gain formula, so navigation improves with what the crawler has learned. | |
| 40 | + | |
| 41 | +## Degradation / self-healing (first step) | |
| 42 | + | |
| 43 | +`PlatformModel.isDegraded(pageType, observed, adapterExpected)` fires `CONNECTOR_DEGRADED` when a well-known page type yields zero entities; `CONNECTOR_REPAIRED` when extraction recovers. Full re-learning (re-inspect DOM/network, regenerate the connector, run regression tests) is Phase 12. | |
| 44 | + | |
| 45 | +## Next steps | |
| 46 | + | |
| 47 | +1. Run authenticated sessions on YouTube and Reddit, inspect field conflicts in the dashboard, tune adapters and the miner. | |
| 48 | +2. Embeddings for novelty (local model) behind `WorldModel.novelty`. | |
| 49 | +3. Facebook adapter (Phase 2) through PLATFORM LEARNING mode first: `pnpm learn facebook` once a GenericAdapter fallback exists. | |
| 50 | +4. Record/replay fixtures from `sessions/<id>/events.jsonl` for regression tests of learned schemas. | |
| 51 | +5. Distributed workers on MacLustr nodes (one browser profile per node), central PostgreSQL. | |
added
package.json
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +{ | |
| 2 | + "name": "social-runtime-crawler", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "description": "Social Runtime Crawler — browser-native, self-learning crawler that mines the observable runtime of social applications.", | |
| 6 | + "type": "module", | |
| 7 | + "packageManager": "pnpm@11.1.2", | |
| 8 | + "engines": { | |
| 9 | + "node": ">=22" | |
| 10 | + }, | |
| 11 | + "scripts": { | |
| 12 | + "src": "tsx apps/worker/src/cli.ts", | |
| 13 | + "login": "tsx apps/worker/src/cli.ts login", | |
| 14 | + "crawl": "tsx apps/worker/src/cli.ts crawl", | |
| 15 | + "learn": "tsx apps/worker/src/cli.ts learn", | |
| 16 | + "replay": "tsx apps/worker/src/cli.ts replay", | |
| 17 | + "dashboard": "tsx apps/api/src/server.ts", | |
| 18 | + "db:migrate": "tsx packages/storage/src/migrate.ts", | |
| 19 | + "typecheck": "tsc -p tsconfig.json --noEmit", | |
| 20 | + "test": "vitest run", | |
| 21 | + "test:watch": "vitest" | |
| 22 | + }, | |
| 23 | + "devDependencies": { | |
| 24 | + "@types/node": "^24.0.0", | |
| 25 | + "tsx": "^4.20.0", | |
| 26 | + "typescript": "^5.9.3", | |
| 27 | + "vitest": "^3.2.0" | |
| 28 | + } | |
| 29 | +} | |
added
packages/agent/package.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/agent", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*", | |
| 14 | + "@anthropic-ai/sdk": "^0.125.0" | |
| 15 | + } | |
| 16 | +} | |
added
packages/agent/src/InformationGain.ts
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +import { clamp01, jaccard, tokenize, type ActionScore, type AgentMode, type ObservedEntity, type PageState, type SemanticAction } from "@src/shared"; | |
| 2 | +import type { WorldModel } from "./WorldModel.ts"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Information Gain Engine (§26–§27): | |
| 6 | + * gain = novelty × relevance × expected_entity_yield × confidence × source_quality ÷ cost | |
| 7 | + * with penalties for already-seen / duplicate / loop / low-quality / low-relevance. | |
| 8 | + */ | |
| 9 | +export interface GainContext { | |
| 10 | + goal: string; | |
| 11 | + mode: AgentMode; | |
| 12 | + world: WorldModel; | |
| 13 | + state: PageState; | |
| 14 | + recentActionTypes: string[]; // last N action types | |
| 15 | + recentUrls: string[]; | |
| 16 | + stepsWithoutNewEntities: number; | |
| 17 | + /** Learned expectations: action type → average new entities produced (from the platform model). */ | |
| 18 | + learnedYield?: Record<string, number>; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const TYPE_YIELD: Record<string, number> = { | |
| 22 | + OPEN_CHANNEL: 0.8, | |
| 23 | + OPEN_PROFILE: 0.8, | |
| 24 | + OPEN_PAGE: 0.75, | |
| 25 | + OPEN_VIDEO: 0.65, | |
| 26 | + OPEN_POST: 0.65, | |
| 27 | + OPEN_COMMENTS: 0.55, | |
| 28 | + OPEN_ENTITY: 0.6, | |
| 29 | + SCROLL_DOWN: 0.6, | |
| 30 | + SEARCH: 0.85, | |
| 31 | + EXPAND: 0.4, | |
| 32 | + PLAY_VIDEO: 0.3, | |
| 33 | + SCROLL_UP: 0.05, | |
| 34 | + BACK: 0.2, | |
| 35 | + RETURN_TO_FEED: 0.3, | |
| 36 | + FILTER: 0.4, | |
| 37 | + WAIT_FOR_CONTENT: 0.1, | |
| 38 | + END_SESSION: 0.0, | |
| 39 | + FORWARD: 0.1, | |
| 40 | + PAUSE_VIDEO: 0.0, | |
| 41 | + COLLAPSE: 0.0, | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export function relevanceOf(text: string | undefined, goal: string): number { | |
| 45 | + if (!goal.trim()) return 0.6; | |
| 46 | + if (!text) return 0.35; | |
| 47 | + const g = tokenize(goal); | |
| 48 | + const t = tokenize(text); | |
| 49 | + if (g.size === 0 || t.size === 0) return 0.4; | |
| 50 | + let hits = 0; | |
| 51 | + for (const tok of g) if (t.has(tok) || [...t].some((x) => x.startsWith(tok.slice(0, 5)) && tok.length > 4)) hits++; | |
| 52 | + const coverage = hits / g.size; | |
| 53 | + return clamp01(0.3 + 0.7 * coverage + 0.2 * jaccard(g, t)); | |
| 54 | +} | |
| 55 | + | |
| 56 | +function entityOf(ctx: GainContext, a: SemanticAction): ObservedEntity | undefined { | |
| 57 | + return a.target_ref ? ctx.state.entities.find((e) => e.ref === a.target_ref) : undefined; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function scoreActions(ctx: GainContext, actions: SemanticAction[]): ActionScore[] { | |
| 61 | + const scores: ActionScore[] = []; | |
| 62 | + const currentRel = relevanceOf(ctx.state.summary_text.slice(0, 1500), ctx.goal); | |
| 63 | + for (const a of actions) { | |
| 64 | + const penalties: string[] = []; | |
| 65 | + const e = entityOf(ctx, a); | |
| 66 | + let novelty = 0.5; | |
| 67 | + let relevance = 0.5; | |
| 68 | + let confidence = 0.7; | |
| 69 | + let source_quality = 0.7; | |
| 70 | + let yieldExp = ctx.learnedYield?.[a.type] !== undefined ? clamp01(ctx.learnedYield[a.type]! / 10) : TYPE_YIELD[a.type] ?? 0.3; | |
| 71 | + | |
| 72 | + if (e) { | |
| 73 | + novelty = ctx.world.novelty(e); | |
| 74 | + relevance = relevanceOf(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`, ctx.goal); | |
| 75 | + confidence = Math.max(0, ...e.provenance.map((p) => p.confidence)); | |
| 76 | + source_quality = e.provenance.some((p) => p.surface === "network") && e.provenance.some((p) => p.surface === "dom") ? 0.95 : 0.75; | |
| 77 | + if (ctx.world.isVisited(e)) penalties.push("already_seen"); | |
| 78 | + if (/^(navigation|header|sidebar)/.test(e.context ?? "") && ctx.mode !== "learn") penalties.push("low_quality_source"); | |
| 79 | + if (!e.name && !e.text) penalties.push("low_confidence_entity"); | |
| 80 | + // Profiles/channels are hubs: raise yield when the goal is about people/orgs. | |
| 81 | + if ((e.type === "channel" || e.type === "profile") && /personalit|people|person|creator|influenc|chaîne|channel|profil/i.test(ctx.goal)) yieldExp = Math.min(1, yieldExp + 0.15); | |
| 82 | + } else { | |
| 83 | + switch (a.type) { | |
| 84 | + case "SCROLL_DOWN": | |
| 85 | + relevance = currentRel; | |
| 86 | + novelty = ctx.stepsWithoutNewEntities > 2 ? 0.2 : 0.6; | |
| 87 | + if (ctx.recentActionTypes.slice(-4).every((t) => t === "SCROLL_DOWN") && ctx.recentActionTypes.length >= 4) penalties.push("navigation_loop"); | |
| 88 | + break; | |
| 89 | + case "SEARCH": | |
| 90 | + relevance = 0.9; | |
| 91 | + novelty = ctx.recentActionTypes.includes("SEARCH") ? 0.15 : 0.9; | |
| 92 | + if (ctx.state.classification.page_type === "SEARCH_RESULTS") penalties.push("already_seen"); | |
| 93 | + break; | |
| 94 | + case "EXPAND": | |
| 95 | + relevance = currentRel; | |
| 96 | + novelty = 0.5; | |
| 97 | + break; | |
| 98 | + case "PLAY_VIDEO": | |
| 99 | + relevance = currentRel; | |
| 100 | + novelty = ctx.state.classification.page_type === "VIDEO_DETAIL" ? 0.6 : 0.3; | |
| 101 | + break; | |
| 102 | + case "BACK": | |
| 103 | + case "RETURN_TO_FEED": | |
| 104 | + relevance = 0.4; | |
| 105 | + novelty = ctx.stepsWithoutNewEntities > 1 ? 0.6 : 0.2; | |
| 106 | + if (ctx.recentActionTypes.slice(-2).includes(a.type)) penalties.push("navigation_loop"); | |
| 107 | + break; | |
| 108 | + case "END_SESSION": | |
| 109 | + relevance = 0.1; | |
| 110 | + novelty = ctx.stepsWithoutNewEntities > 6 ? 0.9 : 0.05; | |
| 111 | + break; | |
| 112 | + default: | |
| 113 | + break; | |
| 114 | + } | |
| 115 | + } | |
| 116 | + if (ctx.mode === "observe" && a.type !== "SCROLL_DOWN" && a.type !== "WAIT_FOR_CONTENT" && a.type !== "END_SESSION") penalties.push("mode_observe_no_navigation"); | |
| 117 | + if (relevance < 0.35 && e) penalties.push("low_relevance"); | |
| 118 | + | |
| 119 | + let gain = (novelty * relevance * yieldExp * confidence * source_quality) / Math.max(0.25, a.cost); | |
| 120 | + for (const p of penalties) gain *= p === "already_seen" ? 0.05 : p === "navigation_loop" ? 0.15 : p === "mode_observe_no_navigation" ? 0.02 : 0.5; | |
| 121 | + | |
| 122 | + scores.push({ action_id: a.id, novelty, relevance, expected_entity_yield: yieldExp, confidence, source_quality, cost: a.cost, penalties, information_gain: gain }); | |
| 123 | + } | |
| 124 | + return scores.sort((x, y) => y.information_gain - x.information_gain); | |
| 125 | +} | |
added
packages/agent/src/LoopDetector.ts
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +/** | |
| 2 | + * Navigation safety (§41): keep recent page fingerprints / urls / actions and detect A→B→A→B patterns. | |
| 3 | + */ | |
| 4 | +export class LoopDetector { | |
| 5 | + private fingerprints: string[] = []; | |
| 6 | + private actions: string[] = []; | |
| 7 | + constructor(private readonly window = 12) {} | |
| 8 | + | |
| 9 | + record(pageFingerprint: string, actionKey: string): void { | |
| 10 | + this.fingerprints.push(pageFingerprint); | |
| 11 | + this.actions.push(actionKey); | |
| 12 | + if (this.fingerprints.length > this.window) this.fingerprints.shift(); | |
| 13 | + if (this.actions.length > this.window) this.actions.shift(); | |
| 14 | + } | |
| 15 | + | |
| 16 | + /** True when the last four page states alternate (A B A B) or the same page repeats 4×. */ | |
| 17 | + detect(): { loop: boolean; pattern?: string } { | |
| 18 | + const f = this.fingerprints; | |
| 19 | + if (f.length >= 4) { | |
| 20 | + const [a, b, c, d] = f.slice(-4); | |
| 21 | + if (a === c && b === d && a !== b) return { loop: true, pattern: "ABAB" }; | |
| 22 | + if (a === b && b === c && c === d) return { loop: true, pattern: "AAAA" }; | |
| 23 | + } | |
| 24 | + const acts = this.actions.slice(-6); | |
| 25 | + if (acts.length === 6 && new Set(acts).size === 1 && !acts[0]!.startsWith("SCROLL_DOWN")) return { loop: true, pattern: "same_action×6" }; | |
| 26 | + return { loop: false }; | |
| 27 | + } | |
| 28 | + | |
| 29 | + recentActionTypes(): string[] { | |
| 30 | + return this.actions.map((a) => a.split(":")[0]!); | |
| 31 | + } | |
| 32 | +} | |
added
packages/agent/src/WorldModel.ts
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +import { jaccard, tokenize, type EntityType, type ObservedEntity, type RelationType } from "@src/shared"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Social World Model (§23): an in-memory graph of everything observed in this session. | |
| 5 | + * Guides navigation (visited set, novelty) and becomes the seed of the persisted graph. | |
| 6 | + */ | |
| 7 | +export interface WorldNode { | |
| 8 | + fingerprint: string; | |
| 9 | + type: EntityType; | |
| 10 | + name?: string; | |
| 11 | + url?: string; | |
| 12 | + platform_id?: string; | |
| 13 | + first_seen_step: number; | |
| 14 | + last_seen_step: number; | |
| 15 | + seen_count: number; | |
| 16 | + visited: boolean; // we navigated to it | |
| 17 | + surfaces: Set<string>; | |
| 18 | + tokens: Set<string>; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface WorldEdge { | |
| 22 | + from: string; | |
| 23 | + to: string; | |
| 24 | + type: RelationType; | |
| 25 | + step: number; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export class WorldModel { | |
| 29 | + readonly nodes = new Map<string, WorldNode>(); | |
| 30 | + readonly edges: WorldEdge[] = []; | |
| 31 | + readonly visitedUrls = new Set<string>(); | |
| 32 | + private edgeKeys = new Set<string>(); | |
| 33 | + | |
| 34 | + /** Register observed entities; returns those never seen before. */ | |
| 35 | + observe(entities: ObservedEntity[], step: number, discoveredFrom?: string): ObservedEntity[] { | |
| 36 | + const fresh: ObservedEntity[] = []; | |
| 37 | + for (const e of entities) { | |
| 38 | + const n = this.nodes.get(e.fingerprint); | |
| 39 | + if (n) { | |
| 40 | + n.last_seen_step = step; | |
| 41 | + n.seen_count++; | |
| 42 | + if (!n.name && e.name) n.name = e.name; | |
| 43 | + if (!n.url && e.url) n.url = e.url; | |
| 44 | + for (const p of e.provenance) n.surfaces.add(p.surface); | |
| 45 | + if (e.name || e.text) n.tokens = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`); | |
| 46 | + } else { | |
| 47 | + this.nodes.set(e.fingerprint, { | |
| 48 | + fingerprint: e.fingerprint, | |
| 49 | + type: e.type, | |
| 50 | + name: e.name, | |
| 51 | + url: e.url, | |
| 52 | + platform_id: e.platform_id, | |
| 53 | + first_seen_step: step, | |
| 54 | + last_seen_step: step, | |
| 55 | + seen_count: 1, | |
| 56 | + visited: false, | |
| 57 | + surfaces: new Set(e.provenance.map((p) => p.surface)), | |
| 58 | + tokens: tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`), | |
| 59 | + }); | |
| 60 | + fresh.push(e); | |
| 61 | + if (discoveredFrom) this.relate(discoveredFrom, e.fingerprint, "DISCOVERED_FROM", step); | |
| 62 | + } | |
| 63 | + if (e.author && e.author_url) { | |
| 64 | + // author edge when we know the author's page | |
| 65 | + this.relate(`author:${e.author_url}`, e.fingerprint, "AUTHORED", step); | |
| 66 | + } | |
| 67 | + } | |
| 68 | + return fresh; | |
| 69 | + } | |
| 70 | + | |
| 71 | + relate(from: string, to: string, type: RelationType, step: number): void { | |
| 72 | + const k = `${from}→${to}:${type}`; | |
| 73 | + if (this.edgeKeys.has(k)) return; | |
| 74 | + this.edgeKeys.add(k); | |
| 75 | + this.edges.push({ from, to, type, step }); | |
| 76 | + } | |
| 77 | + | |
| 78 | + markVisited(fingerprint: string | undefined, url: string | undefined): void { | |
| 79 | + if (fingerprint) { | |
| 80 | + const n = this.nodes.get(fingerprint); | |
| 81 | + if (n) n.visited = true; | |
| 82 | + } | |
| 83 | + if (url) this.visitedUrls.add(url); | |
| 84 | + } | |
| 85 | + | |
| 86 | + isVisited(e: ObservedEntity): boolean { | |
| 87 | + return (this.nodes.get(e.fingerprint)?.visited ?? false) || (!!e.url && this.visitedUrls.has(e.url)); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** Lexical novelty: 1 − max Jaccard similarity to what we already know (embedding-ready seam, §27). */ | |
| 91 | + novelty(e: ObservedEntity): number { | |
| 92 | + const t = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`); | |
| 93 | + if (t.size === 0) return 0.5; | |
| 94 | + let max = 0; | |
| 95 | + let checked = 0; | |
| 96 | + for (const n of this.nodes.values()) { | |
| 97 | + if (n.fingerprint === e.fingerprint) continue; | |
| 98 | + if (n.tokens.size === 0) continue; | |
| 99 | + const s = jaccard(t, n.tokens); | |
| 100 | + if (s > max) max = s; | |
| 101 | + if (++checked > 2000) break; | |
| 102 | + } | |
| 103 | + return 1 - max; | |
| 104 | + } | |
| 105 | + | |
| 106 | + stats() { | |
| 107 | + const byType: Record<string, number> = {}; | |
| 108 | + let visited = 0; | |
| 109 | + let multi = 0; | |
| 110 | + for (const n of this.nodes.values()) { | |
| 111 | + byType[n.type] = (byType[n.type] ?? 0) + 1; | |
| 112 | + if (n.visited) visited++; | |
| 113 | + if (n.surfaces.size > 1) multi++; | |
| 114 | + } | |
| 115 | + return { nodes: this.nodes.size, edges: this.edges.length, visited, multi_surface: multi, by_type: byType }; | |
| 116 | + } | |
| 117 | + | |
| 118 | + toJSON() { | |
| 119 | + return { | |
| 120 | + nodes: [...this.nodes.values()].map((n) => ({ ...n, surfaces: [...n.surfaces], tokens: undefined })), | |
| 121 | + edges: this.edges, | |
| 122 | + }; | |
| 123 | + } | |
| 124 | +} | |
added
packages/agent/src/agent.test.ts
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import type { ObservedEntity, PageState, SemanticAction } from "@src/shared"; | |
| 3 | +import { WorldModel } from "./WorldModel.ts"; | |
| 4 | +import { LoopDetector } from "./LoopDetector.ts"; | |
| 5 | +import { scoreActions, relevanceOf } from "./InformationGain.ts"; | |
| 6 | +import { HeuristicPlanner } from "./planner.ts"; | |
| 7 | + | |
| 8 | +const ent = (ref: string, name: string, type: ObservedEntity["type"] = "video"): ObservedEntity => ({ ref, type, platform: "youtube", platform_id: ref, url: `https://youtube.com/watch?v=${ref}`, name, fields: {}, provenance: [{ surface: "dom", confidence: 0.9 }, { surface: "network", confidence: 0.95 }], fingerprint: `youtube:${type}:${ref}` }); | |
| 9 | + | |
| 10 | +const state = (entities: ObservedEntity[], actions: SemanticAction[]): PageState => ({ url: "https://youtube.com/results?search_query=ai+quebec", title: "results", platform: "youtube", classification: { page_type: "SEARCH_RESULTS", confidence: 0.95, signals: [] }, entities, actions, media: [], summary_text: "AI Québec results", fingerprint: "fp", captured_at: new Date().toISOString() }); | |
| 11 | + | |
| 12 | +describe("information gain + planner", () => { | |
| 13 | + it("prefers relevant, unvisited, multi-surface entities and penalizes visited ones", async () => { | |
| 14 | + const world = new WorldModel(); | |
| 15 | + const e1 = ent("v1", "Intelligence artificielle au Québec : table ronde"); | |
| 16 | + const e2 = ent("v2", "Recette de tarte aux pommes"); | |
| 17 | + world.observe([e1, e2], 0); | |
| 18 | + world.markVisited(e2.fingerprint, e2.url); | |
| 19 | + const actions: SemanticAction[] = [ | |
| 20 | + { id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open v1", cost: 2 }, | |
| 21 | + { id: "A2", type: "OPEN_VIDEO", target_ref: "v2", target_url: e2.url, label: "open v2", cost: 2 }, | |
| 22 | + { id: "A3", type: "SCROLL_DOWN", label: "scroll", cost: 1 }, | |
| 23 | + { id: "A4", type: "END_SESSION", label: "end", cost: 0.5 }, | |
| 24 | + ]; | |
| 25 | + const st = state([e1, e2], actions); | |
| 26 | + const scores = scoreActions({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions); | |
| 27 | + expect(scores[0]!.action_id).toBe("A1"); | |
| 28 | + const visited = scores.find((s) => s.action_id === "A2")!; | |
| 29 | + expect(visited.penalties).toContain("already_seen"); | |
| 30 | + expect(scores.at(-1)!.action_id).toBe("A4"); | |
| 31 | + const d = await new HeuristicPlanner().plan({ goal: "intelligence artificielle Québec", mode: "research", world, state: st, recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, 1); | |
| 32 | + expect(d.chosen_action.id).toBe("A1"); | |
| 33 | + expect(d.reason.length).toBeGreaterThan(0); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it("relevance follows lexical overlap with the goal", () => { | |
| 37 | + expect(relevanceOf("AI Quebec panel", "AI Quebec")).toBeGreaterThan(relevanceOf("Apple pie recipe", "AI Quebec")); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("observe mode suppresses navigation", () => { | |
| 41 | + const world = new WorldModel(); | |
| 42 | + const e1 = ent("v1", "AI"); | |
| 43 | + const actions: SemanticAction[] = [{ id: "A1", type: "OPEN_VIDEO", target_ref: "v1", target_url: e1.url, label: "open", cost: 2 }, { id: "A2", type: "SCROLL_DOWN", label: "scroll", cost: 1 }]; | |
| 44 | + const scores = scoreActions({ goal: "AI", mode: "observe", world, state: state([e1], actions), recentActionTypes: [], recentUrls: [], stepsWithoutNewEntities: 0 }, actions); | |
| 45 | + expect(scores[0]!.action_id).toBe("A2"); | |
| 46 | + }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +describe("LoopDetector", () => { | |
| 50 | + it("detects ABAB", () => { | |
| 51 | + const l = new LoopDetector(); | |
| 52 | + for (const f of ["a", "b", "a", "b"]) l.record(f, "OPEN_VIDEO:x"); | |
| 53 | + expect(l.detect().loop).toBe(true); | |
| 54 | + }); | |
| 55 | + it("tolerates repeated scrolling", () => { | |
| 56 | + const l = new LoopDetector(); | |
| 57 | + for (let i = 0; i < 6; i++) l.record(`p${i}`, "SCROLL_DOWN:"); | |
| 58 | + expect(l.detect().loop).toBe(false); | |
| 59 | + }); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +describe("WorldModel novelty", () => { | |
| 63 | + it("scores near-duplicates as low novelty", () => { | |
| 64 | + const w = new WorldModel(); | |
| 65 | + w.observe([ent("a", "Intelligence artificielle Québec conférence 2026")], 0); | |
| 66 | + expect(w.novelty(ent("b", "Intelligence artificielle Québec conférence 2026 partie 2"))).toBeLessThan(0.5); | |
| 67 | + expect(w.novelty(ent("c", "Tarte aux pommes maison"))).toBeGreaterThan(0.9); | |
| 68 | + }); | |
| 69 | +}); | |
added
packages/agent/src/index.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +export * from "./WorldModel.ts"; | |
| 2 | +export * from "./InformationGain.ts"; | |
| 3 | +export * from "./LoopDetector.ts"; | |
| 4 | +export * from "./llm.ts"; | |
| 5 | +export * from "./planner.ts"; | |
added
packages/agent/src/llm.ts
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +import Anthropic from "@anthropic-ai/sdk"; | |
| 2 | +import { createLogger, type AppConfig } from "@src/shared"; | |
| 3 | + | |
| 4 | +const log = createLogger("llm"); | |
| 5 | + | |
| 6 | +/** Tier 3/4 model access (§49–§50). One tiny interface so the planner never depends on a vendor SDK. */ | |
| 7 | +export interface LlmClient { | |
| 8 | + readonly name: string; | |
| 9 | + complete(opts: { system: string; user: string; maxTokens?: number }): Promise<{ text: string; input_tokens: number; output_tokens: number }>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +class AnthropicClient implements LlmClient { | |
| 13 | + readonly name: string; | |
| 14 | + private client = new Anthropic(); | |
| 15 | + constructor(private readonly model: string) { | |
| 16 | + this.name = `anthropic:${model}`; | |
| 17 | + } | |
| 18 | + async complete(opts: { system: string; user: string; maxTokens?: number }) { | |
| 19 | + const res = await this.client.messages.create({ | |
| 20 | + model: this.model, | |
| 21 | + max_tokens: opts.maxTokens ?? 2000, | |
| 22 | + thinking: { type: "adaptive" }, | |
| 23 | + output_config: { effort: "low" }, // planning over a compact action list is routine work | |
| 24 | + system: [{ type: "text", text: opts.system, cache_control: { type: "ephemeral" } }], | |
| 25 | + messages: [{ role: "user", content: opts.user }], | |
| 26 | + }); | |
| 27 | + if (res.stop_reason === "refusal") { | |
| 28 | + log.warn("planner request refused", { category: res.stop_details?.category }); | |
| 29 | + return { text: "", input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens }; | |
| 30 | + } | |
| 31 | + const text = res.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n"); | |
| 32 | + return { text, input_tokens: res.usage.input_tokens, output_tokens: res.usage.output_tokens }; | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** OpenAI-compatible local endpoint (llm-api.io on the MacLustr cluster, llama.cpp, MLX…). */ | |
| 37 | +class LocalOpenAiCompatibleClient implements LlmClient { | |
| 38 | + readonly name: string; | |
| 39 | + constructor(private readonly baseUrl: string, private readonly model: string, private readonly apiKey?: string) { | |
| 40 | + this.name = `local:${model}`; | |
| 41 | + } | |
| 42 | + async complete(opts: { system: string; user: string; maxTokens?: number }) { | |
| 43 | + const res = await fetch(`${this.baseUrl.replace(/\/$/, "")}/chat/completions`, { | |
| 44 | + method: "POST", | |
| 45 | + headers: { "content-type": "application/json", ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}) }, | |
| 46 | + body: JSON.stringify({ model: this.model, max_tokens: opts.maxTokens ?? 1200, temperature: 0.2, messages: [{ role: "system", content: opts.system }, { role: "user", content: opts.user }] }), | |
| 47 | + }); | |
| 48 | + if (!res.ok) throw new Error(`local llm ${res.status}: ${(await res.text()).slice(0, 200)}`); | |
| 49 | + const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } }; | |
| 50 | + return { text: data.choices?.[0]?.message?.content ?? "", input_tokens: data.usage?.prompt_tokens ?? 0, output_tokens: data.usage?.completion_tokens ?? 0 }; | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function createLlmClient(cfg: AppConfig): LlmClient | undefined { | |
| 55 | + if (cfg.llmProvider === "anthropic") return new AnthropicClient(cfg.llmModel); | |
| 56 | + if (cfg.llmProvider === "local") { | |
| 57 | + if (!cfg.localLlmUrl || !cfg.localLlmModel) { | |
| 58 | + log.warn("SRC_LLM_PROVIDER=local requires SRC_LOCAL_LLM_URL and SRC_LOCAL_LLM_MODEL — falling back to heuristic planner"); | |
| 59 | + return undefined; | |
| 60 | + } | |
| 61 | + return new LocalOpenAiCompatibleClient(cfg.localLlmUrl, cfg.localLlmModel, cfg.localLlmKey); | |
| 62 | + } | |
| 63 | + return undefined; | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** Extract the first JSON object from a model reply (tolerates fences and prose). */ | |
| 67 | +export function extractJson(text: string): Record<string, unknown> | undefined { | |
| 68 | + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); | |
| 69 | + const body = fence?.[1] ?? text; | |
| 70 | + const start = body.indexOf("{"); | |
| 71 | + const end = body.lastIndexOf("}"); | |
| 72 | + if (start < 0 || end <= start) return undefined; | |
| 73 | + try { | |
| 74 | + return JSON.parse(body.slice(start, end + 1)) as Record<string, unknown>; | |
| 75 | + } catch { | |
| 76 | + return undefined; | |
| 77 | + } | |
| 78 | +} | |
added
packages/agent/src/planner.ts
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +import { clamp01, createLogger, truncate, type ActionScore, type AgentDecision, type PageState, type SemanticAction } from "@src/shared"; | |
| 2 | +import { scoreActions, type GainContext } from "./InformationGain.ts"; | |
| 3 | +import { extractJson, type LlmClient } from "./llm.ts"; | |
| 4 | + | |
| 5 | +const log = createLogger("planner"); | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Navigation Agent (§24). Two planners share the same contract: | |
| 9 | + * - HeuristicPlanner (Tier 1): argmax information gain, deterministic. | |
| 10 | + * - LlmPlanner (Tier 4): sees the compact page state + top-scored actions and picks one; the answer is | |
| 11 | + * validated against the action list and falls back to the heuristic on any invalid output. | |
| 12 | + * The LLM never sees selectors or raw DOM and cannot invent actions. | |
| 13 | + */ | |
| 14 | +export interface Planner { | |
| 15 | + readonly name: string; | |
| 16 | + plan(ctx: GainContext, step: number): Promise<AgentDecision>; | |
| 17 | + readonly tokensUsed: number; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export class HeuristicPlanner implements Planner { | |
| 21 | + readonly name = "heuristic"; | |
| 22 | + readonly tokensUsed = 0; | |
| 23 | + async plan(ctx: GainContext, step: number): Promise<AgentDecision> { | |
| 24 | + const scores = scoreActions(ctx, ctx.state.actions); | |
| 25 | + return decisionFromScores(ctx, step, scores, "heuristic", undefined); | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function decisionFromScores(ctx: GainContext, step: number, scores: ActionScore[], planner: AgentDecision["planner"], reason?: string, chosenId?: string): AgentDecision { | |
| 30 | + const best = chosenId ? scores.find((s) => s.action_id === chosenId) ?? scores[0]! : scores[0]!; | |
| 31 | + const action = ctx.state.actions.find((a) => a.id === best.action_id)!; | |
| 32 | + const why = reason ?? explain(ctx, action, best); | |
| 33 | + return { | |
| 34 | + step, | |
| 35 | + goal: ctx.goal, | |
| 36 | + chosen_action: action, | |
| 37 | + expected_information_gain: best.information_gain, | |
| 38 | + novelty: best.novelty, | |
| 39 | + relevance: best.relevance, | |
| 40 | + reason: why, | |
| 41 | + planner, | |
| 42 | + scores: scores.slice(0, 12), | |
| 43 | + }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function explain(ctx: GainContext, a: SemanticAction, s: ActionScore): string { | |
| 47 | + const e = a.target_ref ? ctx.state.entities.find((x) => x.ref === a.target_ref) : undefined; | |
| 48 | + const parts: string[] = []; | |
| 49 | + if (e) parts.push(`${e.type} not yet visited`); | |
| 50 | + if (s.novelty > 0.7) parts.push("novel content"); | |
| 51 | + if (s.relevance > 0.7) parts.push("high relevance to goal"); | |
| 52 | + if (s.source_quality > 0.9) parts.push("confirmed by network + DOM"); | |
| 53 | + if (a.type === "SCROLL_DOWN") parts.push("reveal more feed items"); | |
| 54 | + if (a.type === "SEARCH") parts.push("native search seeds the topic"); | |
| 55 | + if (a.type === "END_SESSION") parts.push("no productive action left"); | |
| 56 | + if (s.penalties.length) parts.push(`penalties: ${s.penalties.join(",")}`); | |
| 57 | + return parts.join("; ") || "best information gain"; | |
| 58 | +} | |
| 59 | + | |
| 60 | +const SYSTEM_PROMPT = `You are the navigation planner of a research crawler that observes public social-media content through a normal authenticated browser session (read-only: never like, follow, comment, message or subscribe). | |
| 61 | +You receive a compact description of the current page, the entities visible on it, and a numbered list of allowed semantic actions with heuristic information-gain scores. | |
| 62 | +Choose exactly one action id that best advances the goal while avoiding loops and already-visited entities. | |
| 63 | +Reply with a single JSON object: {"action": "<id>", "expected_information_gain": <0..1>, "reason": "<one concise sentence>"}. No other text.`; | |
| 64 | + | |
| 65 | +export class LlmPlanner implements Planner { | |
| 66 | + readonly name: string; | |
| 67 | + tokensUsed = 0; | |
| 68 | + private failures = 0; | |
| 69 | + constructor(private readonly llm: LlmClient, private readonly fallback: Planner = new HeuristicPlanner(), private readonly maxTokensBudget = 200_000) { | |
| 70 | + this.name = `llm(${llm.name})`; | |
| 71 | + } | |
| 72 | + | |
| 73 | + async plan(ctx: GainContext, step: number): Promise<AgentDecision> { | |
| 74 | + const scores = scoreActions(ctx, ctx.state.actions); | |
| 75 | + if (this.tokensUsed > this.maxTokensBudget || this.failures >= 3) return decisionFromScores(ctx, step, scores, "fallback", "llm budget exhausted or repeated failures"); | |
| 76 | + // Escalate only when the heuristic is unsure (§50): close top scores, or unknown page. | |
| 77 | + const top = scores[0]!; | |
| 78 | + const second = scores[1]; | |
| 79 | + const unsure = !second || top.information_gain - second.information_gain < top.information_gain * 0.25 || ctx.state.classification.confidence < 0.6; | |
| 80 | + if (!unsure) return decisionFromScores(ctx, step, scores, "heuristic"); | |
| 81 | + | |
| 82 | + try { | |
| 83 | + const user = renderPrompt(ctx, scores); | |
| 84 | + const res = await this.llm.complete({ system: SYSTEM_PROMPT, user, maxTokens: 300 }); | |
| 85 | + this.tokensUsed += res.input_tokens + res.output_tokens; | |
| 86 | + const json = extractJson(res.text); | |
| 87 | + const id = typeof json?.action === "string" ? (json.action as string) : undefined; | |
| 88 | + if (!id || !ctx.state.actions.some((a) => a.id === id)) { | |
| 89 | + this.failures++; | |
| 90 | + log.warn("llm returned invalid action, using heuristic", { reply: truncate(res.text, 120) }); | |
| 91 | + return decisionFromScores(ctx, step, scores, "fallback", "invalid llm output"); | |
| 92 | + } | |
| 93 | + this.failures = 0; | |
| 94 | + const d = decisionFromScores(ctx, step, scores, "llm", typeof json?.reason === "string" ? truncate(json.reason as string, 240) : undefined, id); | |
| 95 | + if (typeof json?.expected_information_gain === "number") d.expected_information_gain = clamp01(json.expected_information_gain as number); | |
| 96 | + return d; | |
| 97 | + } catch (err) { | |
| 98 | + this.failures++; | |
| 99 | + log.warn("llm planner error, using heuristic", { err: (err as Error).message }); | |
| 100 | + return decisionFromScores(ctx, step, scores, "fallback", "llm error"); | |
| 101 | + } | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +export function renderPrompt(ctx: GainContext, scores: ActionScore[]): string { | |
| 106 | + const st: PageState = ctx.state; | |
| 107 | + const lines: string[] = []; | |
| 108 | + lines.push(`GOAL: ${ctx.goal}`, `MODE: ${ctx.mode}`, `PLATFORM: ${st.platform}`, `PAGE: ${st.classification.page_type} (${st.classification.confidence.toFixed(2)}) ${truncate(st.title, 80)}`, `URL: ${st.url}`, ""); | |
| 109 | + lines.push("VISIBLE ENTITIES"); | |
| 110 | + for (const e of st.entities.slice(0, 30)) { | |
| 111 | + const flags = [ctx.world.isVisited(e) ? "visited" : "", e.media?.has_video ? "video" : "", e.metrics?.views ? `${e.metrics.views} views` : "", e.metrics?.score ? `score ${e.metrics.score}` : ""].filter(Boolean).join(", "); | |
| 112 | + lines.push(`[${e.ref}] ${e.type}: ${truncate(e.name ?? e.text ?? e.platform_id, 90)}${e.author ? ` — by ${truncate(e.author, 40)}` : ""}${flags ? ` (${flags})` : ""}`); | |
| 113 | + } | |
| 114 | + if (st.entities.length > 30) lines.push(`… ${st.entities.length - 30} more`); | |
| 115 | + lines.push("", "ACTIONS (id · label · heuristic gain)"); | |
| 116 | + const byId = new Map(scores.map((s) => [s.action_id, s])); | |
| 117 | + for (const a of st.actions) { | |
| 118 | + const s = byId.get(a.id); | |
| 119 | + lines.push(`[${a.id}] ${a.label} · gain=${(s?.information_gain ?? 0).toFixed(3)}${s?.penalties.length ? ` · ${s.penalties.join(",")}` : ""}`); | |
| 120 | + } | |
| 121 | + lines.push("", `RECENT ACTIONS: ${ctx.recentActionTypes.slice(-6).join(" → ") || "none"}`, `STEPS WITHOUT NEW ENTITIES: ${ctx.stepsWithoutNewEntities}`); | |
| 122 | + return lines.join("\n"); | |
| 123 | +} | |
added
packages/browser/package.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/browser", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*", | |
| 14 | + "playwright": "^1.63.0" | |
| 15 | + } | |
| 16 | +} | |
added
packages/browser/src/index.ts
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +export * from "./session.ts"; | |
| 2 | +export * from "./login.ts"; | |
added
packages/browser/src/login.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +import readline from "node:readline"; | |
| 2 | +import { createLogger, type Platform } from "@src/shared"; | |
| 3 | +import { SocialBrowserSession } from "./session.ts"; | |
| 4 | + | |
| 5 | +const log = createLogger("login"); | |
| 6 | + | |
| 7 | +export const LOGIN_URLS: Record<Platform, string> = { | |
| 8 | + youtube: "https://www.youtube.com/", | |
| 9 | + reddit: "https://www.reddit.com/login", | |
| 10 | + facebook: "https://www.facebook.com/", | |
| 11 | + instagram: "https://www.instagram.com/", | |
| 12 | + tiktok: "https://www.tiktok.com/login", | |
| 13 | + x: "https://x.com/login", | |
| 14 | + linkedin: "https://www.linkedin.com/login", | |
| 15 | + threads: "https://www.threads.net/login", | |
| 16 | +}; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Human-in-the-loop authentication (§7): open a headed browser on the platform, | |
| 20 | + * let the operator log in, then persist the profile. No credentials are read, typed or stored by the crawler. | |
| 21 | + */ | |
| 22 | +export async function interactiveLogin(opts: { platform: Platform; accountAlias: string; profilesDir: string; channel?: string }): Promise<void> { | |
| 23 | + const session = new SocialBrowserSession({ ...opts, headless: false }); | |
| 24 | + await session.start(); | |
| 25 | + await session.navigate(LOGIN_URLS[opts.platform]); | |
| 26 | + log.info("Browser is open. Log in manually, dismiss consent dialogs, then come back here."); | |
| 27 | + await waitForEnter("Press <Enter> once you are logged in (the profile will be saved)… "); | |
| 28 | + const page = session.getPage(); | |
| 29 | + const cookies = await session.getContext().cookies(); | |
| 30 | + log.info("profile saved", { platform: opts.platform, alias: opts.accountAlias, url: page.url(), cookies: cookies.length, profile: session.profilePath }); | |
| 31 | + await session.stop(); | |
| 32 | +} | |
| 33 | + | |
| 34 | +function waitForEnter(prompt: string): Promise<void> { | |
| 35 | + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); | |
| 36 | + return new Promise((res) => rl.question(prompt, () => (rl.close(), res()))); | |
| 37 | +} | |
added
packages/browser/src/session.ts
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import fs from "node:fs"; | |
| 3 | +import { chromium, type BrowserContext, type Page, type CDPSession } from "playwright"; | |
| 4 | +import { createLogger, newId, nowIso, type Platform, type SessionInfo } from "@src/shared"; | |
| 5 | +import type { EventBus } from "@src/events"; | |
| 6 | + | |
| 7 | +const log = createLogger("browser"); | |
| 8 | + | |
| 9 | +export interface SessionOptions { | |
| 10 | + platform: Platform; | |
| 11 | + accountAlias: string; | |
| 12 | + profilesDir: string; | |
| 13 | + headless: boolean; | |
| 14 | + channel?: string; // "chromium" | "chrome" | "msedge" | |
| 15 | + bus?: EventBus; | |
| 16 | + viewport?: { width: number; height: number }; | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * One persistent Chromium profile per (platform, account) — §7/§8/§65. | |
| 21 | + * Authentication is never automated: a human logs in once (`login` command); the profile keeps the cookies. | |
| 22 | + */ | |
| 23 | +export class SocialBrowserSession { | |
| 24 | + readonly sessionId = newId("sess"); | |
| 25 | + readonly platform: Platform; | |
| 26 | + readonly accountAlias: string; | |
| 27 | + readonly profilePath: string; | |
| 28 | + private context?: BrowserContext; | |
| 29 | + private page?: Page; | |
| 30 | + private cdp?: CDPSession; | |
| 31 | + private info: SessionInfo; | |
| 32 | + private readonly opts: SessionOptions; | |
| 33 | + | |
| 34 | + constructor(opts: SessionOptions) { | |
| 35 | + this.opts = opts; | |
| 36 | + this.platform = opts.platform; | |
| 37 | + this.accountAlias = opts.accountAlias; | |
| 38 | + this.profilePath = path.join(opts.profilesDir, `${opts.platform}-${opts.accountAlias}`); | |
| 39 | + this.info = { | |
| 40 | + session_id: this.sessionId, | |
| 41 | + platform: this.platform, | |
| 42 | + account_alias: this.accountAlias, | |
| 43 | + profile_path: this.profilePath, | |
| 44 | + started_at: nowIso(), | |
| 45 | + navigation_depth: 0, | |
| 46 | + health: "starting", | |
| 47 | + }; | |
| 48 | + } | |
| 49 | + | |
| 50 | + get id(): string { | |
| 51 | + return this.sessionId; | |
| 52 | + } | |
| 53 | + | |
| 54 | + hasProfile(): boolean { | |
| 55 | + return fs.existsSync(path.join(this.profilePath, "Default")) || fs.existsSync(path.join(this.profilePath, "Local State")); | |
| 56 | + } | |
| 57 | + | |
| 58 | + async start(): Promise<void> { | |
| 59 | + fs.mkdirSync(this.profilePath, { recursive: true }); | |
| 60 | + const channel = this.opts.channel && this.opts.channel !== "chromium" ? this.opts.channel : undefined; | |
| 61 | + this.context = await chromium.launchPersistentContext(this.profilePath, { | |
| 62 | + headless: this.opts.headless, | |
| 63 | + channel, | |
| 64 | + viewport: this.opts.viewport ?? { width: 1380, height: 900 }, | |
| 65 | + locale: "fr-CA", | |
| 66 | + timezoneId: "America/Toronto", | |
| 67 | + args: ["--disable-blink-features=AutomationControlled", "--autoplay-policy=no-user-gesture-required"], | |
| 68 | + ignoreDefaultArgs: ["--enable-automation"], | |
| 69 | + }); | |
| 70 | + this.context.setDefaultTimeout(20_000); | |
| 71 | + this.page = this.context.pages()[0] ?? (await this.context.newPage()); | |
| 72 | + // Close extra tabs the platform may open; we keep a single-tab runtime. | |
| 73 | + this.context.on("page", (p) => { | |
| 74 | + if (p !== this.page) p.close().catch(() => {}); | |
| 75 | + }); | |
| 76 | + this.page.on("crash", () => { | |
| 77 | + this.info.health = "crashed"; | |
| 78 | + log.error("page crashed", { session: this.sessionId }); | |
| 79 | + }); | |
| 80 | + this.cdp = await this.context.newCDPSession(this.page); | |
| 81 | + await this.cdp.send("Network.enable").catch(() => {}); | |
| 82 | + this.info.health = "healthy"; | |
| 83 | + log.info("session started", { session: this.sessionId, platform: this.platform, profile: this.profilePath, headless: this.opts.headless }); | |
| 84 | + this.opts.bus?.emit({ | |
| 85 | + event_type: "SESSION_STARTED", | |
| 86 | + platform: this.platform, | |
| 87 | + session_id: this.sessionId, | |
| 88 | + payload: { account_alias: this.accountAlias, profile_path: this.profilePath, headless: this.opts.headless }, | |
| 89 | + }); | |
| 90 | + } | |
| 91 | + | |
| 92 | + async stop(): Promise<void> { | |
| 93 | + this.info.health = "stopped"; | |
| 94 | + await this.context?.close().catch(() => {}); | |
| 95 | + this.opts.bus?.emit({ event_type: "SESSION_ENDED", platform: this.platform, session_id: this.sessionId, payload: { ...this.info } }); | |
| 96 | + log.info("session stopped", { session: this.sessionId }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + getPage(): Page { | |
| 100 | + if (!this.page) throw new Error("session not started"); | |
| 101 | + return this.page; | |
| 102 | + } | |
| 103 | + | |
| 104 | + getContext(): BrowserContext { | |
| 105 | + if (!this.context) throw new Error("session not started"); | |
| 106 | + return this.context; | |
| 107 | + } | |
| 108 | + | |
| 109 | + getCdp(): CDPSession | undefined { | |
| 110 | + return this.cdp; | |
| 111 | + } | |
| 112 | + | |
| 113 | + getInfo(): SessionInfo { | |
| 114 | + return { ...this.info, current_url: this.page?.url() }; | |
| 115 | + } | |
| 116 | + | |
| 117 | + setHealth(h: SessionInfo["health"]): void { | |
| 118 | + this.info.health = h; | |
| 119 | + } | |
| 120 | + | |
| 121 | + recordAction(label: string, depthDelta = 0): void { | |
| 122 | + this.info.last_action = label; | |
| 123 | + this.info.navigation_depth = Math.max(0, this.info.navigation_depth + depthDelta); | |
| 124 | + } | |
| 125 | + | |
| 126 | + async navigate(url: string): Promise<void> { | |
| 127 | + const page = this.getPage(); | |
| 128 | + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 }); | |
| 129 | + this.info.current_url = page.url(); | |
| 130 | + } | |
| 131 | +} | |
added
packages/connectors/package.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/connectors", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/observers": "workspace:*", | |
| 14 | + "playwright": "^1.63.0" | |
| 15 | + } | |
| 16 | +} | |
added
packages/connectors/src/adapter.ts
+182 −0
@@ -0,0 +1,182 @@ | ||
| 1 | +import type { Page } from "playwright"; | |
| 2 | +import { | |
| 3 | + canonicalUrl, | |
| 4 | + parseCount, | |
| 5 | + parseDuration, | |
| 6 | + shortHash, | |
| 7 | + truncate, | |
| 8 | + type EntityType, | |
| 9 | + type Evidenced, | |
| 10 | + type ObservedEntity, | |
| 11 | + type PageType, | |
| 12 | + type Platform, | |
| 13 | + type Provenance, | |
| 14 | + type SemanticAction, | |
| 15 | +} from "@src/shared"; | |
| 16 | +import type { ClassifierHints, DomCandidate, DomSnapshot, PageTypeHint } from "@src/observers"; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * SocialPlatformAdapter (§56/§57). Adapters hold the *platform-specific* knowledge: | |
| 20 | + * page recognition, semantic labels, entity URL grammar, normalization quirks. | |
| 21 | + * Everything generic (browser lifecycle, observers, planner, storage) lives outside. | |
| 22 | + */ | |
| 23 | +export interface UrlEntityRule { | |
| 24 | + pattern: RegExp; // tested against pathname+search of an absolute URL | |
| 25 | + type: EntityType; | |
| 26 | + /** Extract platform id from the match. */ | |
| 27 | + id: (m: RegExpMatchArray, url: URL) => string | undefined; | |
| 28 | + confidence: number; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface SocialPlatformAdapter { | |
| 32 | + readonly platform: Platform; | |
| 33 | + readonly homeUrl: string; | |
| 34 | + readonly hosts: RegExp; | |
| 35 | + readonly pageTypeHints: PageTypeHint[]; | |
| 36 | + readonly urlEntityRules: UrlEntityRule[]; | |
| 37 | + readonly classifierHints: ClassifierHints; | |
| 38 | + /** Build a search URL for native platform search (§43). */ | |
| 39 | + searchUrl(query: string): string; | |
| 40 | + /** Extra filters when turning DOM links into entities (e.g. skip menu links). */ | |
| 41 | + isNoiseLink?(href: URL, text: string): boolean; | |
| 42 | + /** Human labels for the planner. */ | |
| 43 | + entityLabel(type: EntityType): string; | |
| 44 | + /** Optional per-page tweaks after generic extraction. */ | |
| 45 | + refineEntities?(entities: ObservedEntity[], snapshot: DomSnapshot): ObservedEntity[]; | |
| 46 | + /** Expected minimum entities on a known page type — used for degradation detection (§32). */ | |
| 47 | + expectedEntities(pageType: PageType): number; | |
| 48 | + /** Optional: dismiss consent / cookie dialogs (read-only UI hygiene). */ | |
| 49 | + dismissOverlays?(page: Page): Promise<void>; | |
| 50 | +} | |
| 51 | + | |
| 52 | +const OPEN_ACTION: Partial<Record<EntityType, SemanticAction["type"]>> = { | |
| 53 | + video: "OPEN_VIDEO", | |
| 54 | + post: "OPEN_POST", | |
| 55 | + comment: "OPEN_COMMENTS", | |
| 56 | + profile: "OPEN_PROFILE", | |
| 57 | + channel: "OPEN_CHANNEL", | |
| 58 | + page: "OPEN_PAGE", | |
| 59 | + community: "OPEN_PAGE", | |
| 60 | + person: "OPEN_PROFILE", | |
| 61 | + organization: "OPEN_PAGE", | |
| 62 | +}; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * Generic DOM → entity extraction, driven by the adapter's URL grammar (§57: "feed identification, | |
| 66 | + * known entity classes" are platform knowledge; the walk itself is generic). | |
| 67 | + */ | |
| 68 | +export function entitiesFromDom(snapshot: DomSnapshot, adapter: SocialPlatformAdapter): ObservedEntity[] { | |
| 69 | + const out = new Map<string, ObservedEntity>(); | |
| 70 | + let n = 0; | |
| 71 | + for (const c of snapshot.candidates) { | |
| 72 | + if (!c.href) continue; | |
| 73 | + let url: URL; | |
| 74 | + try { | |
| 75 | + url = new URL(c.href); | |
| 76 | + } catch { | |
| 77 | + continue; | |
| 78 | + } | |
| 79 | + if (!adapter.hosts.test(url.hostname)) continue; | |
| 80 | + if (adapter.isNoiseLink?.(url, c.text)) continue; | |
| 81 | + const target = url.pathname + url.search; | |
| 82 | + for (const rule of adapter.urlEntityRules) { | |
| 83 | + const m = target.match(rule.pattern); | |
| 84 | + if (!m) continue; | |
| 85 | + const id = rule.id(m, url); | |
| 86 | + if (!id) break; | |
| 87 | + const fingerprint = `${adapter.platform}:${rule.type}:${id}`; | |
| 88 | + const prov: Provenance[] = [{ surface: "dom", confidence: rule.confidence, detail: `${c.kind} in ${c.region}` }]; | |
| 89 | + const ev = (value: unknown): Evidenced => ({ value, provenance: prov }); | |
| 90 | + const existing = out.get(fingerprint); | |
| 91 | + const text = c.text && c.text.length > 1 ? dedupeRepeatedText(c.text) : undefined; | |
| 92 | + if (existing) { | |
| 93 | + // Merge: a longer text is a better title for the same link target. | |
| 94 | + if (text && (!existing.name || text.length > existing.name.length) && !/^\d+:\d\d/.test(text)) { | |
| 95 | + existing.name = truncate(text, 200); | |
| 96 | + existing.fields.title = ev(text); | |
| 97 | + } | |
| 98 | + if (c.meta?.duration && !existing.media?.duration_s) existing.media = { ...(existing.media ?? { has_video: rule.type === "video", has_image: false }), duration_s: parseDuration(c.meta.duration) }; | |
| 99 | + if (c.meta?.views && !existing.metrics?.views) existing.metrics = { ...(existing.metrics ?? {}), views: parseCount(c.meta.views) }; | |
| 100 | + continue; | |
| 101 | + } | |
| 102 | + const fields: Record<string, Evidenced> = { platform_id: ev(id), url: ev(url.toString()) }; | |
| 103 | + if (text) fields.title = ev(text); | |
| 104 | + if (c.meta?.author) fields.author = ev(c.meta.author); | |
| 105 | + if (c.meta?.duration) fields.duration = ev(c.meta.duration); | |
| 106 | + if (c.meta?.views) fields.views_text = ev(c.meta.views); | |
| 107 | + if (c.meta?.published) fields.published_text = ev(c.meta.published); | |
| 108 | + const metrics: ObservedEntity["metrics"] = {}; | |
| 109 | + const views = parseCount(c.meta?.views); | |
| 110 | + if (views !== undefined) (rule.type === "video" ? (metrics.views = views) : (metrics.score = views)); | |
| 111 | + const comments = parseCount(c.meta?.comments); | |
| 112 | + if (comments !== undefined) metrics.comments = comments; | |
| 113 | + out.set(fingerprint, { | |
| 114 | + ref: `D${++n}`, | |
| 115 | + type: rule.type, | |
| 116 | + platform: adapter.platform, | |
| 117 | + platform_id: id, | |
| 118 | + url: canonicalUrl(url.toString()), | |
| 119 | + name: text ? truncate(text, 200) : undefined, | |
| 120 | + author: c.meta?.author ? truncate(c.meta.author, 120) : undefined, | |
| 121 | + metrics: Object.keys(metrics).length ? metrics : undefined, | |
| 122 | + media: rule.type === "video" ? { has_video: true, has_image: false, duration_s: parseDuration(c.meta?.duration) } : undefined, | |
| 123 | + published_text: c.meta?.published, | |
| 124 | + context: `${c.region}${c.visible ? " (visible)" : ""} #${c.index}`, | |
| 125 | + fields, | |
| 126 | + provenance: prov, | |
| 127 | + fingerprint, | |
| 128 | + }); | |
| 129 | + break; | |
| 130 | + } | |
| 131 | + } | |
| 132 | + const list = [...out.values()]; | |
| 133 | + return adapter.refineEntities ? adapter.refineEntities(list, snapshot) : list; | |
| 134 | +} | |
| 135 | + | |
| 136 | +/** Turn entities + DOM controls into the semantic action list the planner may choose from (§25). */ | |
| 137 | +export function buildActions(entities: ObservedEntity[], snapshot: DomSnapshot, adapter: SocialPlatformAdapter, opts: { query?: string; maxOpen?: number; visited: Set<string> } ): SemanticAction[] { | |
| 138 | + const actions: SemanticAction[] = []; | |
| 139 | + let n = 0; | |
| 140 | + const id = () => `A${++n}`; | |
| 141 | + const openable = entities | |
| 142 | + .filter((e) => e.url && OPEN_ACTION[e.type] && !opts.visited.has(e.fingerprint)) | |
| 143 | + .slice(0, opts.maxOpen ?? 25); | |
| 144 | + for (const e of openable) { | |
| 145 | + actions.push({ | |
| 146 | + id: id(), | |
| 147 | + type: OPEN_ACTION[e.type]!, | |
| 148 | + target_ref: e.ref, | |
| 149 | + target_url: e.url, | |
| 150 | + label: `Open ${adapter.entityLabel(e.type)} ${e.ref}: ${truncate(e.name ?? e.platform_id ?? e.url, 70)}`, | |
| 151 | + cost: 2, | |
| 152 | + }); | |
| 153 | + } | |
| 154 | + const canScroll = snapshot.scroll.y + snapshot.scroll.viewport < snapshot.scroll.height - 50; | |
| 155 | + if (canScroll) actions.push({ id: id(), type: "SCROLL_DOWN", label: "Scroll down to reveal more content", cost: 1 }); | |
| 156 | + if (snapshot.scroll.y > 0) actions.push({ id: id(), type: "SCROLL_UP", label: "Scroll up", cost: 1 }); | |
| 157 | + const expandButtons = snapshot.candidates.filter((c) => c.kind === "button" && c.visible).slice(0, 3); | |
| 158 | + for (const b of expandButtons) actions.push({ id: id(), type: "EXPAND", label: `Expand: ${truncate(b.text, 60)}`, target_url: b.locator_hint, cost: 1.5 }); | |
| 159 | + if (snapshot.video_elements.length && !snapshot.video_elements.some((v) => v.playing)) actions.push({ id: id(), type: "PLAY_VIDEO", label: "Play the visible video (to observe media delivery)", cost: 1.5 }); | |
| 160 | + if (opts.query) actions.push({ id: id(), type: "SEARCH", query: opts.query, label: `Search the platform for “${opts.query}”`, cost: 2 }); | |
| 161 | + actions.push({ id: id(), type: "BACK", label: "Go back", cost: 1.5 }); | |
| 162 | + actions.push({ id: id(), type: "RETURN_TO_FEED", target_url: adapter.homeUrl, label: "Return to the home feed", cost: 2 }); | |
| 163 | + actions.push({ id: id(), type: "END_SESSION", label: "End the session (nothing valuable left)", cost: 0.5 }); | |
| 164 | + return actions; | |
| 165 | +} | |
| 166 | + | |
| 167 | +/** Link text often repeats its accessible label ("Mila Mila", "Title Title 12:34") — keep one copy. */ | |
| 168 | +export function dedupeRepeatedText(text: string): string { | |
| 169 | + const t = text.replace(/\s+/g, " ").trim(); | |
| 170 | + const half = Math.floor(t.length / 2); | |
| 171 | + for (let cut = half; cut >= 4; cut--) { | |
| 172 | + const a = t.slice(0, cut).trim(); | |
| 173 | + const rest = t.slice(cut).trim(); | |
| 174 | + if (rest.startsWith(a) && (rest.length === a.length || rest.length - a.length < 40)) return rest.length === a.length ? a : `${a} ${rest.slice(a.length).trim()}`.trim(); | |
| 175 | + } | |
| 176 | + return t; | |
| 177 | +} | |
| 178 | + | |
| 179 | +export function pageFingerprint(url: string, entities: ObservedEntity[]): string { | |
| 180 | + const ids = entities.map((e) => e.fingerprint).sort().slice(0, 40).join("|"); | |
| 181 | + return shortHash(canonicalUrl(url) + "::" + ids, 16); | |
| 182 | +} | |
added
packages/connectors/src/index.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import type { Platform } from "@src/shared"; | |
| 2 | +import type { SocialPlatformAdapter } from "./adapter.ts"; | |
| 3 | +import { youtubeAdapter } from "./youtube.ts"; | |
| 4 | +import { redditAdapter } from "./reddit.ts"; | |
| 5 | + | |
| 6 | +export * from "./adapter.ts"; | |
| 7 | +export { youtubeAdapter, redditAdapter }; | |
| 8 | + | |
| 9 | +const ADAPTERS: Partial<Record<Platform, SocialPlatformAdapter>> = { | |
| 10 | + youtube: youtubeAdapter, | |
| 11 | + reddit: redditAdapter, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export function getAdapter(platform: Platform): SocialPlatformAdapter { | |
| 15 | + const a = ADAPTERS[platform]; | |
| 16 | + if (!a) throw new Error(`No adapter for platform "${platform}" yet (Phase 1 = youtube, reddit). Use PLATFORM LEARNING mode to bootstrap one.`); | |
| 17 | + return a; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export function hasAdapter(platform: Platform): boolean { | |
| 21 | + return !!ADAPTERS[platform]; | |
| 22 | +} | |
added
packages/connectors/src/reddit.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import type { EntityType, PageType } from "@src/shared"; | |
| 2 | +import type { ClassifierHints } from "@src/observers"; | |
| 3 | +import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts"; | |
| 4 | + | |
| 5 | +/** Reddit adapter — Phase 1 platform (new web UI, `shreddit-*` custom elements, GraphQL + `.json` payloads). */ | |
| 6 | +const classifierHints: ClassifierHints = { | |
| 7 | + platform: "reddit", | |
| 8 | + idKeys: { postId: "post", commentId: "comment", subredditId: "community", authorId: "profile" }, | |
| 9 | + urlForId: (type: EntityType, id: string) => { | |
| 10 | + if (type === "post" && /^t3_/.test(id)) return `https://www.reddit.com/comments/${id.slice(3)}`; | |
| 11 | + if (type === "post" && /^[a-z0-9]{5,8}$/.test(id)) return `https://www.reddit.com/comments/${id}`; | |
| 12 | + return undefined; | |
| 13 | + }, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +const urlEntityRules: UrlEntityRule[] = [ | |
| 17 | + { pattern: /^\/r\/([\w]+)\/comments\/([a-z0-9]+)\/[^/]*\/([a-z0-9]+)/, type: "comment", id: (m) => `t1_${m[3]}`, confidence: 0.9 }, | |
| 18 | + { pattern: /^\/r\/([\w]+)\/comments\/([a-z0-9]+)/, type: "post", id: (m) => `t3_${m[2]}`, confidence: 0.95 }, | |
| 19 | + { pattern: /^\/comments\/([a-z0-9]+)/, type: "post", id: (m) => `t3_${m[1]}`, confidence: 0.9 }, | |
| 20 | + { pattern: /^\/(?:user|u)\/([\w-]+)\/?(?:\?|$)/, type: "profile", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 }, | |
| 21 | + { pattern: /^\/r\/([\w]+)\/?(?:\?|$)/, type: "community", id: (m) => m[1]?.toLowerCase(), confidence: 0.92 }, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +export const redditAdapter: SocialPlatformAdapter = { | |
| 25 | + platform: "reddit", | |
| 26 | + homeUrl: "https://www.reddit.com/", | |
| 27 | + hosts: /(^|\.)reddit\.com$|^redd\.it$/, | |
| 28 | + pageTypeHints: [ | |
| 29 | + { pattern: /^\/search\/?\?/, page_type: "SEARCH_RESULTS", confidence: 0.95 }, | |
| 30 | + { pattern: /\/comments\//, page_type: "POST_DETAIL", confidence: 0.95 }, | |
| 31 | + { pattern: /^\/(user|u)\//, page_type: "PROFILE", confidence: 0.92 }, | |
| 32 | + { pattern: /^\/r\/[\w]+\/?(\?|$|\/(hot|new|top|rising))/, page_type: "GROUP", confidence: 0.9 }, | |
| 33 | + { pattern: /^\/?(\?|$)|^\/(popular|all|best)/, page_type: "HOME_FEED", confidence: 0.9 }, | |
| 34 | + ], | |
| 35 | + urlEntityRules, | |
| 36 | + classifierHints, | |
| 37 | + searchUrl: (q) => `https://www.reddit.com/search/?q=${encodeURIComponent(q)}`, | |
| 38 | + isNoiseLink: (href, text) => /^\/(settings|premium|coins|policies|help|topics|best\/communities|answers)/.test(href.pathname) || /^(Home|Popular|All|Accueil|Populaire)$/i.test(text), | |
| 39 | + entityLabel: (t) => ({ post: "post", comment: "comment", profile: "user", community: "subreddit" } as Partial<Record<EntityType, string>>)[t] ?? t, | |
| 40 | + expectedEntities: (t: PageType) => ({ HOME_FEED: 6, SEARCH_RESULTS: 5, POST_DETAIL: 3, GROUP: 6, PROFILE: 3 } as Partial<Record<PageType, number>>)[t] ?? 0, | |
| 41 | +}; | |
added
packages/connectors/src/youtube.ts
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +import type { Page } from "playwright"; | |
| 2 | +import type { EntityType, ObservedEntity, PageType } from "@src/shared"; | |
| 3 | +import type { ClassifierHints, DomSnapshot } from "@src/observers"; | |
| 4 | +import type { SocialPlatformAdapter, UrlEntityRule } from "./adapter.ts"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * YouTube adapter — Phase 1 platform. Holds only platform knowledge: | |
| 8 | + * URL grammar, page hints, rich-text quirks (`runs` / `simpleText`) and id key names. | |
| 9 | + * Nothing here hardcodes a YouTube endpoint: the network layer fingerprints them. | |
| 10 | + */ | |
| 11 | +const collapseYouTubeText = (v: unknown): string | undefined => { | |
| 12 | + if (!v || typeof v !== "object") return undefined; | |
| 13 | + const o = v as Record<string, unknown>; | |
| 14 | + if (typeof o.simpleText === "string") return o.simpleText; | |
| 15 | + if (Array.isArray(o.runs)) { | |
| 16 | + const s = o.runs.map((r) => (r && typeof r === "object" && typeof (r as Record<string, unknown>).text === "string" ? (r as Record<string, string>).text : "")).join(""); | |
| 17 | + return s || undefined; | |
| 18 | + } | |
| 19 | + if (typeof o.content === "string") return o.content; | |
| 20 | + return undefined; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +const classifierHints: ClassifierHints = { | |
| 24 | + platform: "youtube", | |
| 25 | + textCollapsers: [collapseYouTubeText], | |
| 26 | + idKeys: { videoId: "video", channelId: "channel", commentId: "comment", playlistId: "post" }, | |
| 27 | + urlForId: (type: EntityType, id: string) => { | |
| 28 | + if (type === "video") return `https://www.youtube.com/watch?v=${id}`; | |
| 29 | + if (type === "channel") return `https://www.youtube.com/channel/${id}`; | |
| 30 | + return undefined; | |
| 31 | + }, | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const urlEntityRules: UrlEntityRule[] = [ | |
| 35 | + { pattern: /^\/watch\?(?:.*&)?v=([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 }, | |
| 36 | + { pattern: /^\/shorts\/([A-Za-z0-9_-]{11})/, type: "video", id: (m) => m[1], confidence: 0.95 }, | |
| 37 | + { pattern: /^\/(@[\w.-]{3,60})(?:\/|$|\?)/, type: "channel", id: (m) => m[1], confidence: 0.92 }, | |
| 38 | + { pattern: /^\/channel\/(UC[\w-]{20,})/, type: "channel", id: (m) => m[1], confidence: 0.95 }, | |
| 39 | + { pattern: /^\/c\/([\w.-]+)/, type: "channel", id: (m) => `c/${m[1]}`, confidence: 0.85 }, | |
| 40 | + { pattern: /^\/user\/([\w.-]+)/, type: "channel", id: (m) => `user/${m[1]}`, confidence: 0.85 }, | |
| 41 | + { pattern: /^\/post\/([\w-]+)/, type: "post", id: (m) => m[1], confidence: 0.9 }, | |
| 42 | + { pattern: /^\/hashtag\/([\w-]+)/, type: "hashtag", id: (m) => m[1]?.toLowerCase(), confidence: 0.9 }, | |
| 43 | +]; | |
| 44 | + | |
| 45 | +export const youtubeAdapter: SocialPlatformAdapter = { | |
| 46 | + platform: "youtube", | |
| 47 | + homeUrl: "https://www.youtube.com/", | |
| 48 | + hosts: /(^|\.)youtube\.com$|^youtu\.be$/, | |
| 49 | + pageTypeHints: [ | |
| 50 | + { pattern: /^\/results\?/, page_type: "SEARCH_RESULTS", confidence: 0.95 }, | |
| 51 | + { pattern: /^\/watch\?/, page_type: "VIDEO_DETAIL", confidence: 0.95 }, | |
| 52 | + { pattern: /^\/shorts\//, page_type: "VIDEO_DETAIL", confidence: 0.9 }, | |
| 53 | + { pattern: /^\/(@[\w.-]+|channel\/|c\/|user\/)/, page_type: "CHANNEL", confidence: 0.92 }, | |
| 54 | + { pattern: /^\/post\//, page_type: "POST_DETAIL", confidence: 0.9 }, | |
| 55 | + { pattern: /^\/feed\//, page_type: "HOME_FEED", confidence: 0.85 }, | |
| 56 | + { pattern: /^\/?$/, page_type: "HOME_FEED", confidence: 0.9 }, | |
| 57 | + ], | |
| 58 | + urlEntityRules, | |
| 59 | + classifierHints, | |
| 60 | + searchUrl: (q) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`, | |
| 61 | + isNoiseLink: (href, text) => /^\/(feed\/(library|history|subscriptions)|premium|account|reporthistory|paid_memberships|t\/|about|howyoutubeworks|new|creators|ads)/.test(href.pathname) || /^(Accueil|Home|Shorts|Abonnements|Subscriptions|Vous|You|Historique|History)$/i.test(text), | |
| 62 | + entityLabel: (t) => ({ video: "video", channel: "channel", post: "community post", comment: "comment", hashtag: "hashtag" } as Partial<Record<EntityType, string>>)[t] ?? t, | |
| 63 | + refineEntities: (entities: ObservedEntity[], snapshot: DomSnapshot) => { | |
| 64 | + // On a watch page, the entity for the current video should be first and marked as "current". | |
| 65 | + const m = snapshot.url.match(/[?&]v=([A-Za-z0-9_-]{11})/); | |
| 66 | + if (m) { | |
| 67 | + const cur = entities.find((e) => e.type === "video" && e.platform_id === m[1]); | |
| 68 | + if (cur) { | |
| 69 | + cur.context = "current video"; | |
| 70 | + if (snapshot.h1 && (!cur.name || cur.name.length < snapshot.h1.length)) cur.name = snapshot.h1; | |
| 71 | + return [cur, ...entities.filter((e) => e !== cur)]; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + return entities; | |
| 75 | + }, | |
| 76 | + expectedEntities: (t: PageType) => ({ HOME_FEED: 8, SEARCH_RESULTS: 8, VIDEO_DETAIL: 5, CHANNEL: 4 } as Partial<Record<PageType, number>>)[t] ?? 0, | |
| 77 | + dismissOverlays: async (page: Page) => { | |
| 78 | + // Consent / "Sign in" nags — clicking a *dismiss* control is read-only UI hygiene, not an interaction with content. | |
| 79 | + for (const sel of ['button[aria-label*="Accept" i]', 'button[aria-label*="Accepter" i]', 'tp-yt-paper-dialog #dismiss-button', 'ytd-popup-container button[aria-label*="No thanks" i]', 'ytd-popup-container button[aria-label*="Non merci" i]']) { | |
| 80 | + const b = page.locator(sel).first(); | |
| 81 | + if (await b.isVisible().catch(() => false)) { | |
| 82 | + await b.click({ timeout: 2000 }).catch(() => {}); | |
| 83 | + } | |
| 84 | + } | |
| 85 | + }, | |
| 86 | +}; | |
added
packages/entities/package.json
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/entities", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*" | |
| 14 | + } | |
| 15 | +} | |
added
packages/entities/src/entities.test.ts
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import type { ObservedEntity } from "@src/shared"; | |
| 3 | +import { mergeSurfaces, resolveIdentity, entityConfidence } from "./index.ts"; | |
| 4 | + | |
| 5 | +const mk = (surface: "dom" | "network", over: Partial<ObservedEntity>): ObservedEntity => ({ ref: "x", type: "video", platform: "youtube", platform_id: "v1", fingerprint: "youtube:video:v1", fields: { title: { value: over.name, provenance: [{ surface, confidence: surface === "network" ? 0.95 : 0.85 }] } }, provenance: [{ surface, confidence: surface === "network" ? 0.95 : 0.85 }], ...over }); | |
| 6 | + | |
| 7 | +describe("mergeSurfaces", () => { | |
| 8 | + it("merges the same entity from network and DOM, keeping both provenances", () => { | |
| 9 | + const net = [mk("network", { name: "IA au Québec — table ronde", author: "Chaîne", metrics: { views: 1200000 } })]; | |
| 10 | + const dom = [mk("dom", { name: "IA au Québec — table ronde", url: "https://youtube.com/watch?v=v1" })]; | |
| 11 | + const r = mergeSurfaces(net, dom); | |
| 12 | + expect(r.merged).toHaveLength(1); | |
| 13 | + expect(r.both).toBe(1); | |
| 14 | + expect(r.field_agreements).toBe(1); | |
| 15 | + const e = r.merged[0]!; | |
| 16 | + expect(new Set(e.provenance.map((p) => p.surface))).toEqual(new Set(["dom", "network"])); | |
| 17 | + expect(e.author).toBe("Chaîne"); | |
| 18 | + expect(e.url).toContain("v1"); | |
| 19 | + expect(e.ref).toBe("E1"); | |
| 20 | + expect(entityConfidence(e)).toBeGreaterThan(0.95); | |
| 21 | + }); | |
| 22 | + it("reports conflicts", () => { | |
| 23 | + const r = mergeSurfaces([mk("network", { name: "Titre A" })], [mk("dom", { name: "Complètement autre" })]); | |
| 24 | + expect(r.field_conflicts).toHaveLength(1); | |
| 25 | + }); | |
| 26 | +}); | |
| 27 | + | |
| 28 | +describe("resolveIdentity", () => { | |
| 29 | + it("never merges on display name alone", () => { | |
| 30 | + const d = resolveIdentity({ id: "a", display_name: "Simon B", urls: [], usernames: [] }, { id: "b", display_name: "Simon B", urls: [], usernames: [] }); | |
| 31 | + expect(d.merge).toBe(false); | |
| 32 | + }); | |
| 33 | + it("merges with a shared canonical url + username", () => { | |
| 34 | + const d = resolveIdentity({ id: "a", display_name: "Simon B", urls: ["https://example.com"], usernames: ["@simon"] }, { id: "b", urls: ["https://example.com"], usernames: ["simon"] }); | |
| 35 | + expect(d.merge).toBe(true); | |
| 36 | + expect(d.evidence).toContain("same canonical url"); | |
| 37 | + }); | |
| 38 | +}); | |
added
packages/entities/src/index.ts
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +import { clamp01, type Evidenced, type ObservedEntity, type Provenance } from "@src/shared"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Entity Extraction Layer (§5 step 6, §37–§39): merge the same entity seen on several surfaces, | |
| 5 | + * keep provenance per field, compare network vs DOM evidence. | |
| 6 | + */ | |
| 7 | + | |
| 8 | +export interface MergeReport { | |
| 9 | + merged: ObservedEntity[]; | |
| 10 | + network_only: number; | |
| 11 | + dom_only: number; | |
| 12 | + both: number; | |
| 13 | + field_agreements: number; | |
| 14 | + field_conflicts: { fingerprint: string; field: string; network: unknown; dom: unknown }[]; | |
| 15 | +} | |
| 16 | + | |
| 17 | +const COMPARABLE_FIELDS = ["title", "author", "duration", "views_text"] as const; | |
| 18 | + | |
| 19 | +function normalizeForCompare(v: unknown): string { | |
| 20 | + return String(v ?? "").toLowerCase().replace(/\s+/g, " ").trim(); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function mergeSurfaces(network: ObservedEntity[], dom: ObservedEntity[]): MergeReport { | |
| 24 | + const byFp = new Map<string, ObservedEntity>(); | |
| 25 | + const report: MergeReport = { merged: [], network_only: 0, dom_only: 0, both: 0, field_agreements: 0, field_conflicts: [] }; | |
| 26 | + | |
| 27 | + for (const e of dom) byFp.set(e.fingerprint, cloneEntity(e)); | |
| 28 | + for (const n of network) { | |
| 29 | + const d = byFp.get(n.fingerprint); | |
| 30 | + if (!d) { | |
| 31 | + byFp.set(n.fingerprint, cloneEntity(n)); | |
| 32 | + report.network_only++; | |
| 33 | + continue; | |
| 34 | + } | |
| 35 | + report.both++; | |
| 36 | + // Combine provenance and fields | |
| 37 | + d.provenance = dedupeProv([...d.provenance, ...n.provenance]); | |
| 38 | + for (const [k, nv] of Object.entries(n.fields)) { | |
| 39 | + const dv = d.fields[k]; | |
| 40 | + if (!dv) { | |
| 41 | + d.fields[k] = nv; | |
| 42 | + continue; | |
| 43 | + } | |
| 44 | + if ((COMPARABLE_FIELDS as readonly string[]).includes(k)) { | |
| 45 | + const a = normalizeForCompare(dv.value); | |
| 46 | + const b = normalizeForCompare(nv.value); | |
| 47 | + if (a && b && (a === b || a.includes(b) || b.includes(a))) report.field_agreements++; | |
| 48 | + else if (a && b) report.field_conflicts.push({ fingerprint: n.fingerprint, field: k, network: nv.value, dom: dv.value }); | |
| 49 | + } | |
| 50 | + d.fields[k] = { value: preferValue(dv, nv), provenance: dedupeProv([...dv.provenance, ...nv.provenance]) }; | |
| 51 | + } | |
| 52 | + d.name = d.name && n.name ? (n.name.length >= d.name.length ? n.name : d.name) : (d.name ?? n.name); | |
| 53 | + d.text = d.text ?? n.text; | |
| 54 | + d.author = d.author ?? n.author; | |
| 55 | + // Structured network numbers beat numbers scraped from card text. | |
| 56 | + d.metrics = { ...(d.metrics ?? {}), ...(n.metrics ?? {}) }; | |
| 57 | + if (Object.keys(d.metrics).length === 0) d.metrics = undefined; | |
| 58 | + d.media = d.media || n.media ? { has_video: !!(d.media?.has_video || n.media?.has_video), has_image: !!(d.media?.has_image || n.media?.has_image), duration_s: d.media?.duration_s ?? n.media?.duration_s, thumbnail_url: d.media?.thumbnail_url ?? n.media?.thumbnail_url } : undefined; | |
| 59 | + d.url = d.url ?? n.url; | |
| 60 | + } | |
| 61 | + report.dom_only = dom.length - report.both; | |
| 62 | + // Re-number refs so the planner sees E1..En in a stable order (DOM order first, then network-only). | |
| 63 | + let i = 0; | |
| 64 | + for (const e of byFp.values()) e.ref = `E${++i}`; | |
| 65 | + report.merged = [...byFp.values()]; | |
| 66 | + return report; | |
| 67 | +} | |
| 68 | + | |
| 69 | +function preferValue(a: Evidenced, b: Evidenced): unknown { | |
| 70 | + const ca = Math.max(...a.provenance.map((p) => p.confidence)); | |
| 71 | + const cb = Math.max(...b.provenance.map((p) => p.confidence)); | |
| 72 | + if (typeof a.value === "string" && typeof b.value === "string" && Math.abs(ca - cb) < 0.1) return a.value.length >= b.value.length ? a.value : b.value; | |
| 73 | + return cb > ca ? b.value : a.value; | |
| 74 | +} | |
| 75 | + | |
| 76 | +function dedupeProv(list: Provenance[]): Provenance[] { | |
| 77 | + const seen = new Map<string, Provenance>(); | |
| 78 | + for (const p of list) { | |
| 79 | + const k = p.surface + "|" + (p.detail ?? ""); | |
| 80 | + const prev = seen.get(k); | |
| 81 | + if (!prev || prev.confidence < p.confidence) seen.set(k, p); | |
| 82 | + } | |
| 83 | + return [...seen.values()].sort((a, b) => b.confidence - a.confidence); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function cloneEntity(e: ObservedEntity): ObservedEntity { | |
| 87 | + return { ...e, fields: { ...e.fields }, provenance: [...e.provenance], metrics: e.metrics ? { ...e.metrics } : undefined, media: e.media ? { ...e.media } : undefined }; | |
| 88 | +} | |
| 89 | + | |
| 90 | +/** Overall entity confidence = best provenance, boosted when two surfaces agree (§59). */ | |
| 91 | +export function entityConfidence(e: ObservedEntity): number { | |
| 92 | + const surfaces = new Set(e.provenance.map((p) => p.surface)); | |
| 93 | + const best = Math.max(0, ...e.provenance.map((p) => p.confidence)); | |
| 94 | + return clamp01(best + (surfaces.size > 1 ? 0.05 : 0) - (!e.name && !e.text ? 0.2 : 0)); | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** | |
| 98 | + * IdentityResolver (§21) — minimal skeleton. Never merges on names alone: requires at least | |
| 99 | + * one strong signal (same canonical url, cross-link, same platform id). Fuzzy names only add evidence. | |
| 100 | + */ | |
| 101 | +export interface IdentityCandidate { | |
| 102 | + id: string; | |
| 103 | + display_name?: string; | |
| 104 | + urls: string[]; | |
| 105 | + usernames: string[]; | |
| 106 | +} | |
| 107 | + | |
| 108 | +export interface MatchDecision { | |
| 109 | + candidate_a: string; | |
| 110 | + candidate_b: string; | |
| 111 | + match_probability: number; | |
| 112 | + evidence: string[]; | |
| 113 | + merge: boolean; | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function resolveIdentity(a: IdentityCandidate, b: IdentityCandidate): MatchDecision { | |
| 117 | + const evidence: string[] = []; | |
| 118 | + let p = 0; | |
| 119 | + const urlsA = new Set(a.urls.map((u) => u.toLowerCase())); | |
| 120 | + if (b.urls.some((u) => urlsA.has(u.toLowerCase()))) { | |
| 121 | + evidence.push("same canonical url"); | |
| 122 | + p += 0.7; | |
| 123 | + } | |
| 124 | + const uA = new Set(a.usernames.map((u) => u.toLowerCase().replace(/^@/, ""))); | |
| 125 | + if (b.usernames.some((u) => uA.has(u.toLowerCase().replace(/^@/, "")))) { | |
| 126 | + evidence.push("same username"); | |
| 127 | + p += 0.35; | |
| 128 | + } | |
| 129 | + if (a.display_name && b.display_name && a.display_name.toLowerCase() === b.display_name.toLowerCase()) { | |
| 130 | + evidence.push("same display name (weak)"); | |
| 131 | + p += 0.1; | |
| 132 | + } | |
| 133 | + const prob = clamp01(p); | |
| 134 | + return { candidate_a: a.id, candidate_b: b.id, match_probability: prob, evidence, merge: prob >= 0.8 && evidence.some((e) => !e.includes("weak")) }; | |
| 135 | +} | |
added
packages/events/package.json
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/events", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*" | |
| 13 | + } | |
| 14 | +} | |
added
packages/events/src/index.ts
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { newId, nowIso, type Platform, type Provenance } from "@src/shared"; | |
| 4 | + | |
| 5 | +/** Universal Social Event Format (§33, §34). */ | |
| 6 | +export type SocialEventType = | |
| 7 | + | "SESSION_STARTED" | |
| 8 | + | "SESSION_ENDED" | |
| 9 | + | "PAGE_OPENED" | |
| 10 | + | "PAGE_CLASSIFIED" | |
| 11 | + | "ENTITY_DISCOVERED" | |
| 12 | + | "ENTITY_OBSERVED" | |
| 13 | + | "ENTITY_UPDATED" | |
| 14 | + | "POST_DISCOVERED" | |
| 15 | + | "COMMENT_DISCOVERED" | |
| 16 | + | "PROFILE_DISCOVERED" | |
| 17 | + | "MEDIA_DISCOVERED" | |
| 18 | + | "VIDEO_DISCOVERED" | |
| 19 | + | "IMAGE_DISCOVERED" | |
| 20 | + | "FEED_ITEM_OBSERVED" | |
| 21 | + | "NETWORK_RESPONSE_OBSERVED" | |
| 22 | + | "NETWORK_SCHEMA_DISCOVERED" | |
| 23 | + | "WEBSOCKET_FRAME_OBSERVED" | |
| 24 | + | "DOM_CHANGED" | |
| 25 | + | "ACTION_PLANNED" | |
| 26 | + | "ACTION_EXECUTED" | |
| 27 | + | "ACTION_FAILED" | |
| 28 | + | "NAVIGATION_COMPLETED" | |
| 29 | + | "LOOP_DETECTED" | |
| 30 | + | "BUDGET_EXHAUSTED" | |
| 31 | + | "AUTH_REQUIRED" | |
| 32 | + | "CONNECTOR_PATTERN_LEARNED" | |
| 33 | + | "CONNECTOR_DEGRADED" | |
| 34 | + | "CONNECTOR_REPAIRED" | |
| 35 | + | "WORKER_ERROR"; | |
| 36 | + | |
| 37 | +export interface SocialEvent<T = Record<string, unknown>> { | |
| 38 | + event_id: string; | |
| 39 | + event_type: SocialEventType; | |
| 40 | + platform: Platform; | |
| 41 | + session_id: string; | |
| 42 | + step?: number; | |
| 43 | + timestamp: string; | |
| 44 | + payload: T; | |
| 45 | + provenance?: Provenance[]; | |
| 46 | + discovered_via?: { action?: string; action_id?: string; url?: string }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export type EventHandler = (ev: SocialEvent) => void | Promise<void>; | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * Typed in-process event bus. Observers publish, storage / learner / dashboard subscribe. | |
| 53 | + * Handlers are awaited sequentially so persistence stays ordered. | |
| 54 | + */ | |
| 55 | +export class EventBus { | |
| 56 | + private handlers: { type: SocialEventType | "*"; fn: EventHandler }[] = []; | |
| 57 | + private queue: Promise<void> = Promise.resolve(); | |
| 58 | + public count = 0; | |
| 59 | + | |
| 60 | + on(type: SocialEventType | "*", fn: EventHandler): () => void { | |
| 61 | + const h = { type, fn }; | |
| 62 | + this.handlers.push(h); | |
| 63 | + return () => { | |
| 64 | + this.handlers = this.handlers.filter((x) => x !== h); | |
| 65 | + }; | |
| 66 | + } | |
| 67 | + | |
| 68 | + emit<T extends Record<string, unknown>>( | |
| 69 | + partial: Omit<SocialEvent<T>, "event_id" | "timestamp"> & { event_id?: string; timestamp?: string }, | |
| 70 | + ): SocialEvent<T> { | |
| 71 | + const ev: SocialEvent<T> = { | |
| 72 | + event_id: partial.event_id ?? newId("ev"), | |
| 73 | + timestamp: partial.timestamp ?? nowIso(), | |
| 74 | + ...partial, | |
| 75 | + }; | |
| 76 | + this.count++; | |
| 77 | + const targets = this.handlers.filter((h) => h.type === "*" || h.type === ev.event_type); | |
| 78 | + this.queue = this.queue.then(async () => { | |
| 79 | + for (const h of targets) { | |
| 80 | + try { | |
| 81 | + await h.fn(ev as SocialEvent); | |
| 82 | + } catch (err) { | |
| 83 | + process.stderr.write(`[events] handler error on ${ev.event_type}: ${(err as Error).message}\n`); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + }); | |
| 87 | + return ev; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** Wait for all queued handlers (call before shutdown). */ | |
| 91 | + flush(): Promise<void> { | |
| 92 | + return this.queue; | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Append-only JSONL session log — the raw record every replay (§54) is built from. */ | |
| 97 | +export class JsonlEventLog { | |
| 98 | + private stream: fs.WriteStream; | |
| 99 | + readonly file: string; | |
| 100 | + | |
| 101 | + constructor(sessionDir: string) { | |
| 102 | + fs.mkdirSync(sessionDir, { recursive: true }); | |
| 103 | + this.file = path.join(sessionDir, "events.jsonl"); | |
| 104 | + this.stream = fs.createWriteStream(this.file, { flags: "a" }); | |
| 105 | + } | |
| 106 | + | |
| 107 | + write(ev: SocialEvent): void { | |
| 108 | + this.stream.write(JSON.stringify(ev) + "\n"); | |
| 109 | + } | |
| 110 | + | |
| 111 | + attach(bus: EventBus): () => void { | |
| 112 | + return bus.on("*", (ev) => this.write(ev)); | |
| 113 | + } | |
| 114 | + | |
| 115 | + close(): Promise<void> { | |
| 116 | + return new Promise((res) => this.stream.end(res)); | |
| 117 | + } | |
| 118 | + | |
| 119 | + static read(sessionDir: string): SocialEvent[] { | |
| 120 | + const file = path.join(sessionDir, "events.jsonl"); | |
| 121 | + if (!fs.existsSync(file)) return []; | |
| 122 | + return fs | |
| 123 | + .readFileSync(file, "utf8") | |
| 124 | + .split("\n") | |
| 125 | + .filter(Boolean) | |
| 126 | + .map((l) => JSON.parse(l) as SocialEvent); | |
| 127 | + } | |
| 128 | +} | |
added
packages/media/package.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/media", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*", | |
| 14 | + "playwright": "^1.63.0" | |
| 15 | + } | |
| 16 | +} | |
added
packages/media/src/index.ts
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import type { Page } from "playwright"; | |
| 4 | +import { createLogger, shortHash, sleep, type MediaLevel, type ObservedEntity, type ObservedMedia, type Platform, type Provenance } from "@src/shared"; | |
| 5 | +import type { ClassifiedResponse, DomSnapshot } from "@src/observers"; | |
| 6 | + | |
| 7 | +const log = createLogger("media"); | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Media Intelligence Layer (§17–§19, §47). Video detection fuses three surfaces: | |
| 11 | + * - DOM <video> elements (playback state, dimensions, duration) | |
| 12 | + * - network media manifests / segments (delivery kind + CDN hostnames) | |
| 13 | + * - entities of type video (platform id, title, author, thumbnail) | |
| 14 | + * Frame sampling (LEVEL 2) captures a few element screenshots at fixed positions — no download of the artifact. | |
| 15 | + */ | |
| 16 | +export function detectVideos(opts: { platform: Platform; snapshot: DomSnapshot; entities: ObservedEntity[]; responses: ClassifiedResponse[]; pageUrl: string }): ObservedMedia[] { | |
| 17 | + const out = new Map<string, ObservedMedia>(); | |
| 18 | + const mediaResponses = opts.responses.filter((r) => r.kind === "media_manifest" || r.kind === "media_segment"); | |
| 19 | + const hostnames = [...new Set(mediaResponses.map((r) => r.fingerprint.hostname))]; | |
| 20 | + const manifest = mediaResponses.find((r) => r.kind === "media_manifest"); | |
| 21 | + const deliveryKind: "progressive" | "hls" | "dash" | "unknown" = manifest | |
| 22 | + ? /m3u8|mpegurl/i.test(manifest.response.url + manifest.response.content_type) | |
| 23 | + ? "hls" | |
| 24 | + : /mpd|dash/i.test(manifest.response.url + manifest.response.content_type) | |
| 25 | + ? "dash" | |
| 26 | + : "unknown" | |
| 27 | + : mediaResponses.length | |
| 28 | + ? "progressive" | |
| 29 | + : "unknown"; | |
| 30 | + | |
| 31 | + // 1) Current video (the one playing / present in DOM) — bind it to the current-page video entity when there is one. | |
| 32 | + const current = opts.entities.find((e) => e.type === "video" && (e.context === "current video" || (e.url && samePage(e.url, opts.pageUrl)))); | |
| 33 | + for (const v of opts.snapshot.video_elements) { | |
| 34 | + if (!(v.width > 0 || v.duration || v.current_src)) continue; | |
| 35 | + const id = current?.platform_id ?? shortHash(v.current_src ?? v.src ?? opts.pageUrl, 12); | |
| 36 | + const fp = `${opts.platform}:video:${id}`; | |
| 37 | + const prov: Provenance[] = [{ surface: "dom", confidence: 0.9, detail: "video element" }]; | |
| 38 | + if (mediaResponses.length) prov.push({ surface: "network", confidence: 0.85, detail: `${mediaResponses.length} media responses` }); | |
| 39 | + if (current) prov.push(...current.provenance); | |
| 40 | + out.set(fp, { | |
| 41 | + media_type: "video", | |
| 42 | + platform: opts.platform, | |
| 43 | + platform_media_id: current?.platform_id ?? undefined, | |
| 44 | + url: v.current_src ?? v.src, | |
| 45 | + page_url: opts.pageUrl, | |
| 46 | + title: current?.name ?? opts.snapshot.h1, | |
| 47 | + author: current?.author, | |
| 48 | + duration_s: v.duration ?? current?.media?.duration_s, | |
| 49 | + width: v.width || undefined, | |
| 50 | + height: v.height || undefined, | |
| 51 | + thumbnail_url: v.poster ?? current?.media?.thumbnail_url, | |
| 52 | + delivery: { kind: deliveryKind, manifest_url: manifest?.response.url, hostnames }, | |
| 53 | + fingerprint: fp, | |
| 54 | + provenance: prov, | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + // 2) Video entities visible on the page (feed cards, search results): metadata-level media records. | |
| 58 | + for (const e of opts.entities) { | |
| 59 | + if (e.type !== "video" || out.has(e.fingerprint)) continue; | |
| 60 | + out.set(e.fingerprint, { | |
| 61 | + media_type: "video", | |
| 62 | + platform: opts.platform, | |
| 63 | + platform_media_id: e.platform_id, | |
| 64 | + page_url: e.url, | |
| 65 | + title: e.name, | |
| 66 | + author: e.author, | |
| 67 | + duration_s: e.media?.duration_s, | |
| 68 | + thumbnail_url: e.media?.thumbnail_url, | |
| 69 | + fingerprint: e.fingerprint, | |
| 70 | + provenance: e.provenance, | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + return [...out.values()]; | |
| 74 | +} | |
| 75 | + | |
| 76 | +function samePage(a: string, b: string): boolean { | |
| 77 | + try { | |
| 78 | + const ua = new URL(a); | |
| 79 | + const ub = new URL(b); | |
| 80 | + return ua.pathname === ub.pathname && ua.searchParams.get("v") === ub.searchParams.get("v"); | |
| 81 | + } catch { | |
| 82 | + return false; | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +/** | |
| 87 | + * VideoFrameSampler (§18): LEVEL ≥ 2 — screenshot the <video> element at 0 / 25 / 50 / 75 / 100 %. | |
| 88 | + * Seeking a video the account is legitimately watching is normal viewer behaviour. | |
| 89 | + * Returns file paths; perceptual hashing is left as a follow-up (MediaDeduplicator seam). | |
| 90 | + */ | |
| 91 | +export async function sampleVideoFrames(page: Page, media: ObservedMedia, mediaDir: string, level: MediaLevel, maxFrames = 5): Promise<string[]> { | |
| 92 | + if (level < 2) return []; | |
| 93 | + const video = page.locator("video").first(); | |
| 94 | + if (!(await video.isVisible().catch(() => false))) return []; | |
| 95 | + const dir = path.join(mediaDir, media.platform, media.platform_media_id ?? shortHash(media.fingerprint, 10)); | |
| 96 | + fs.mkdirSync(dir, { recursive: true }); | |
| 97 | + const duration = media.duration_s ?? (await video.evaluate((v: HTMLVideoElement) => (Number.isFinite(v.duration) ? v.duration : 0)).catch(() => 0)); | |
| 98 | + const positions = [0, 0.25, 0.5, 0.75, 0.98].slice(0, maxFrames); | |
| 99 | + const files: string[] = []; | |
| 100 | + for (const p of positions) { | |
| 101 | + try { | |
| 102 | + if (duration > 2) { | |
| 103 | + await video.evaluate((v: HTMLVideoElement, t: number) => { | |
| 104 | + v.currentTime = t; | |
| 105 | + }, Math.min(duration - 0.5, duration * p)); | |
| 106 | + await sleep(600); | |
| 107 | + } | |
| 108 | + const file = path.join(dir, `frame_${Math.round(p * 100)}.jpg`); | |
| 109 | + await video.screenshot({ path: file, type: "jpeg", quality: 70, timeout: 5000 }); | |
| 110 | + files.push(file); | |
| 111 | + if (duration <= 2) break; | |
| 112 | + } catch (err) { | |
| 113 | + log.debug("frame capture failed", { err: (err as Error).message }); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + return files; | |
| 117 | +} | |
added
packages/observers/package.json
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/observers", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*", | |
| 14 | + "playwright": "^1.63.0" | |
| 15 | + } | |
| 16 | +} | |
added
packages/observers/src/PageClassifier.test.ts
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { classifyPage } from "./PageClassifier.ts"; | |
| 3 | +import type { DomSnapshot } from "./dom/PageSummarizer.ts"; | |
| 4 | +import { youtubeAdapter, redditAdapter } from "@src/connectors"; | |
| 5 | + | |
| 6 | +const base = (url: string, extra: Partial<DomSnapshot> = {}): DomSnapshot => ({ url, title: "t", landmarks: ["main", "navigation"], candidates: [], video_elements: [], text_excerpt: "", scroll: { y: 0, height: 5000, viewport: 900 }, has_login_form: false, dialog_open: false, ...extra }); | |
| 7 | + | |
| 8 | +describe("classifyPage", () => { | |
| 9 | + it("uses adapter url hints", () => { | |
| 10 | + expect(classifyPage(base("https://www.youtube.com/results?search_query=ai"), youtubeAdapter.pageTypeHints).page_type).toBe("SEARCH_RESULTS"); | |
| 11 | + expect(classifyPage(base("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), youtubeAdapter.pageTypeHints).page_type).toBe("VIDEO_DETAIL"); | |
| 12 | + expect(classifyPage(base("https://www.youtube.com/@someone/videos"), youtubeAdapter.pageTypeHints).page_type).toBe("CHANNEL"); | |
| 13 | + expect(classifyPage(base("https://www.reddit.com/r/quebec/comments/abc123/titre/"), redditAdapter.pageTypeHints).page_type).toBe("POST_DETAIL"); | |
| 14 | + expect(classifyPage(base("https://www.reddit.com/r/quebec/"), redditAdapter.pageTypeHints).page_type).toBe("GROUP"); | |
| 15 | + }); | |
| 16 | + it("flags login pages and unknown pages without throwing", () => { | |
| 17 | + expect(classifyPage(base("https://example.com/x", { has_login_form: true })).page_type).toBe("LOGIN"); | |
| 18 | + const u = classifyPage(base("https://example.com/whatever")); | |
| 19 | + expect(u.page_type).toBe("UNKNOWN"); | |
| 20 | + expect(u.confidence).toBeLessThan(0.5); | |
| 21 | + }); | |
| 22 | +}); | |
added
packages/observers/src/PageClassifier.ts
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +import type { PageClassification, PageType } from "@src/shared"; | |
| 2 | +import type { DomSnapshot } from "./dom/PageSummarizer.ts"; | |
| 3 | + | |
| 4 | +/** A URL-pattern hint supplied by a platform adapter (§58). */ | |
| 5 | +export interface PageTypeHint { | |
| 6 | + pattern: RegExp; // tested against pathname + search | |
| 7 | + page_type: PageType; | |
| 8 | + confidence: number; | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Generic page classifier combining URL hints, DOM landmarks and content density (§58–§60). | |
| 13 | + * Returns UNKNOWN with low confidence rather than throwing — unknown pages are expected. | |
| 14 | + */ | |
| 15 | +export function classifyPage(snapshot: DomSnapshot, hints: PageTypeHint[] = []): PageClassification { | |
| 16 | + const signals: string[] = []; | |
| 17 | + let url: URL | undefined; | |
| 18 | + try { | |
| 19 | + url = new URL(snapshot.url); | |
| 20 | + } catch { | |
| 21 | + /* ignore */ | |
| 22 | + } | |
| 23 | + const scores = new Map<PageType, number>(); | |
| 24 | + const bump = (t: PageType, c: number, why: string) => { | |
| 25 | + scores.set(t, Math.max(scores.get(t) ?? 0, c)); | |
| 26 | + signals.push(`${why}→${t}`); | |
| 27 | + }; | |
| 28 | + | |
| 29 | + if (snapshot.has_login_form) bump("LOGIN", 0.9, "password field"); | |
| 30 | + | |
| 31 | + if (url) { | |
| 32 | + const target = url.pathname + url.search; | |
| 33 | + for (const h of hints) if (h.pattern.test(target)) bump(h.page_type, h.confidence, `url:${h.pattern.source.slice(0, 30)}`); | |
| 34 | + if (target === "/" || target === "") bump("HOME_FEED", 0.6, "root path"); | |
| 35 | + if (/search|results|\?q=|\?search_query=/i.test(target)) bump("SEARCH_RESULTS", 0.7, "search in url"); | |
| 36 | + } | |
| 37 | + | |
| 38 | + const links = snapshot.candidates.filter((c) => c.kind === "link"); | |
| 39 | + const articles = snapshot.candidates.filter((c) => c.kind === "article"); | |
| 40 | + const mainLinks = links.filter((c) => c.region === "main" || c.region === "feed").length; | |
| 41 | + const hasVideo = snapshot.video_elements.some((v) => v.width > 0 || v.duration); | |
| 42 | + const commentsRegion = snapshot.candidates.some((c) => c.region === "comments"); | |
| 43 | + | |
| 44 | + if (hasVideo && snapshot.video_elements.length === 1 && snapshot.landmarks.length > 2) bump("VIDEO_DETAIL", 0.6, "single video element"); | |
| 45 | + if (commentsRegion && (hasVideo || articles.length <= 2)) bump(hasVideo ? "VIDEO_DETAIL" : "POST_DETAIL", 0.55, "comments region"); | |
| 46 | + if (articles.length >= 5 && !hasVideo) bump("HOME_FEED", 0.5, `${articles.length} articles`); | |
| 47 | + if (mainLinks >= 15 && articles.length >= 5 && scores.get("SEARCH_RESULTS") === undefined) bump("HOME_FEED", 0.45, "dense feed"); | |
| 48 | + if (snapshot.candidates.some((c) => c.kind === "search" && c.visible) && snapshot.candidates.some((c) => c.kind === "heading" && /result|résultat/i.test(c.text))) bump("SEARCH_RESULTS", 0.65, "results heading"); | |
| 49 | + | |
| 50 | + if (scores.size === 0) return { page_type: "UNKNOWN", confidence: 0.2, signals: ["no signal"] }; | |
| 51 | + const [best, conf] = [...scores.entries()].sort((a, b) => b[1] - a[1])[0]!; | |
| 52 | + return { page_type: best, confidence: Math.min(0.99, conf), signals: signals.slice(0, 12) }; | |
| 53 | +} | |
added
packages/observers/src/dom/DomObserver.ts
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +import type { Page } from "playwright"; | |
| 2 | +import { createLogger, type Platform } from "@src/shared"; | |
| 3 | +import type { EventBus } from "@src/events"; | |
| 4 | + | |
| 5 | +const log = createLogger("dom"); | |
| 6 | + | |
| 7 | +export interface DomDelta { | |
| 8 | + added: number; | |
| 9 | + removed: number; | |
| 10 | + text_changes: number; | |
| 11 | + attr_changes: number; | |
| 12 | + regions: Record<string, number>; // landmark/role region → nodes added | |
| 13 | + new_videos: number; | |
| 14 | + new_articles: number; | |
| 15 | + new_links: number; | |
| 16 | + modal_opened: boolean; | |
| 17 | + url: string; | |
| 18 | + at: number; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Injected before any page script (§13). Buffers MutationObserver deltas and flushes | |
| 23 | + * them to Node every 500 ms through an exposed binding. Never serializes the whole DOM. | |
| 24 | + */ | |
| 25 | +const INJECTED = String.raw` | |
| 26 | +(() => { | |
| 27 | + if (window.__srcDomObserverInstalled) return; | |
| 28 | + window.__srcDomObserverInstalled = true; | |
| 29 | + const state = { added: 0, removed: 0, text_changes: 0, attr_changes: 0, regions: {}, new_videos: 0, new_articles: 0, new_links: 0, modal_opened: false }; | |
| 30 | + const regionOf = (node) => { | |
| 31 | + let el = node.nodeType === 1 ? node : node.parentElement; | |
| 32 | + let hops = 0; | |
| 33 | + while (el && hops++ < 25) { | |
| 34 | + const role = el.getAttribute && (el.getAttribute('role') || ''); | |
| 35 | + const tag = el.tagName ? el.tagName.toLowerCase() : ''; | |
| 36 | + if (role === 'main' || tag === 'main') return 'main'; | |
| 37 | + if (role === 'feed' ) return 'feed'; | |
| 38 | + if (role === 'dialog' || el.getAttribute && el.getAttribute('aria-modal') === 'true') return 'dialog'; | |
| 39 | + if (role === 'navigation' || tag === 'nav') return 'navigation'; | |
| 40 | + if (role === 'complementary' || tag === 'aside') return 'sidebar'; | |
| 41 | + if (tag === 'header' || role === 'banner') return 'header'; | |
| 42 | + if (tag === 'ytd-comments' || (el.id && /comment/i.test(el.id))) return 'comments'; | |
| 43 | + el = el.parentElement; | |
| 44 | + } | |
| 45 | + return 'unknown'; | |
| 46 | + }; | |
| 47 | + const count = (node) => { | |
| 48 | + if (node.nodeType !== 1) return; | |
| 49 | + const el = node; | |
| 50 | + const tag = el.tagName.toLowerCase(); | |
| 51 | + if (tag === 'video' || el.querySelector && el.querySelector('video')) state.new_videos++; | |
| 52 | + if (tag === 'article' || el.getAttribute('role') === 'article' || (el.querySelectorAll && el.querySelectorAll('article,[role=article]').length)) state.new_articles++; | |
| 53 | + if (tag === 'a' ) state.new_links++; else if (el.querySelectorAll) state.new_links += Math.min(50, el.querySelectorAll('a[href]').length); | |
| 54 | + if (el.getAttribute('role') === 'dialog' || el.getAttribute('aria-modal') === 'true') state.modal_opened = true; | |
| 55 | + }; | |
| 56 | + const obs = new MutationObserver((muts) => { | |
| 57 | + for (const m of muts) { | |
| 58 | + if (m.type === 'childList') { | |
| 59 | + if (m.addedNodes.length) { | |
| 60 | + state.added += m.addedNodes.length; | |
| 61 | + const r = regionOf(m.target); | |
| 62 | + state.regions[r] = (state.regions[r] || 0) + m.addedNodes.length; | |
| 63 | + m.addedNodes.forEach(count); | |
| 64 | + } | |
| 65 | + state.removed += m.removedNodes.length; | |
| 66 | + } else if (m.type === 'characterData') state.text_changes++; | |
| 67 | + else if (m.type === 'attributes') state.attr_changes++; | |
| 68 | + } | |
| 69 | + }); | |
| 70 | + const start = () => obs.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['src', 'href', 'aria-expanded', 'aria-hidden', 'hidden', 'class'] }); | |
| 71 | + if (document.documentElement) start(); else document.addEventListener('DOMContentLoaded', start); | |
| 72 | + setInterval(() => { | |
| 73 | + if (!state.added && !state.removed && !state.text_changes && !state.attr_changes) return; | |
| 74 | + const snapshot = Object.assign({}, state, { url: location.href, at: Date.now() }); | |
| 75 | + state.added = 0; state.removed = 0; state.text_changes = 0; state.attr_changes = 0; state.regions = {}; state.new_videos = 0; state.new_articles = 0; state.new_links = 0; state.modal_opened = false; | |
| 76 | + if (window.__srcEmitDomDelta) window.__srcEmitDomDelta(snapshot); | |
| 77 | + }, 500); | |
| 78 | +})(); | |
| 79 | +`; | |
| 80 | + | |
| 81 | +export class DomObserver { | |
| 82 | + private deltas: DomDelta[] = []; | |
| 83 | + private stepDeltas: DomDelta[] = []; | |
| 84 | + private step = 0; | |
| 85 | + private installed = false; | |
| 86 | + | |
| 87 | + constructor(private readonly opts: { page: Page; bus: EventBus; sessionId: string; platform: Platform }) {} | |
| 88 | + | |
| 89 | + async attach(): Promise<void> { | |
| 90 | + if (this.installed) return; | |
| 91 | + const { page } = this.opts; | |
| 92 | + await page.exposeBinding("__srcEmitDomDelta", (_src, delta: DomDelta) => this.onDelta(delta)); | |
| 93 | + await page.addInitScript(INJECTED); | |
| 94 | + // For the page already loaded, install immediately too. | |
| 95 | + await page.evaluate(INJECTED).catch(() => {}); | |
| 96 | + this.installed = true; | |
| 97 | + log.debug("dom observer attached"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + beginStep(step: number): void { | |
| 101 | + this.step = step; | |
| 102 | + this.stepDeltas = []; | |
| 103 | + } | |
| 104 | + | |
| 105 | + collectStep(): DomDelta[] { | |
| 106 | + return [...this.stepDeltas]; | |
| 107 | + } | |
| 108 | + | |
| 109 | + private onDelta(delta: DomDelta): void { | |
| 110 | + this.deltas.push(delta); | |
| 111 | + this.stepDeltas.push(delta); | |
| 112 | + if (this.deltas.length > 5000) this.deltas.splice(0, 1000); | |
| 113 | + if (delta.added + delta.removed < 3 && delta.text_changes < 3 && !delta.modal_opened) return; // noise | |
| 114 | + this.opts.bus.emit({ | |
| 115 | + event_type: "DOM_CHANGED", | |
| 116 | + platform: this.opts.platform, | |
| 117 | + session_id: this.opts.sessionId, | |
| 118 | + step: this.step, | |
| 119 | + payload: { | |
| 120 | + added: delta.added, | |
| 121 | + removed: delta.removed, | |
| 122 | + text_changes: delta.text_changes, | |
| 123 | + attr_changes: delta.attr_changes, | |
| 124 | + regions: delta.regions, | |
| 125 | + new_videos: delta.new_videos, | |
| 126 | + new_articles: delta.new_articles, | |
| 127 | + new_links: delta.new_links, | |
| 128 | + modal_opened: delta.modal_opened, | |
| 129 | + url: delta.url, | |
| 130 | + }, | |
| 131 | + provenance: [{ surface: "dom", confidence: 0.9 }], | |
| 132 | + }); | |
| 133 | + } | |
| 134 | +} | |
added
packages/observers/src/dom/PageSummarizer.ts
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | +import type { Page } from "playwright"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Raw DOM candidates scanned in-page (§13–§15). Generic: relies on links, landmarks, ARIA roles, | |
| 8 | + * media elements and text density — never on generated class names. Adapters refine these later. | |
| 9 | + * | |
| 10 | + * The in-page code lives in `snapshot.page.js` (plain JS) because TypeScript transpilers inject | |
| 11 | + * helpers such as `__name` into function bodies, which do not exist inside the page. | |
| 12 | + */ | |
| 13 | +export interface DomCandidate { | |
| 14 | + kind: "link" | "article" | "video" | "image" | "heading" | "search" | "button"; | |
| 15 | + href?: string; | |
| 16 | + text: string; // visible text (trimmed) | |
| 17 | + aria_label?: string; | |
| 18 | + role?: string; | |
| 19 | + region: string; // main / feed / sidebar / dialog / header / navigation / comments / unknown | |
| 20 | + index: number; // document order among candidates | |
| 21 | + visible: boolean; // within viewport | |
| 22 | + top: number; // bounding rect top (for feed position) | |
| 23 | + meta?: Record<string, string>; // duration, views, author text found in the same card | |
| 24 | + locator_hint: string; // a stable-ish CSS selector path we can use to click (prefers href / aria-label) | |
| 25 | +} | |
| 26 | + | |
| 27 | +export interface DomSnapshot { | |
| 28 | + url: string; | |
| 29 | + title: string; | |
| 30 | + lang?: string; | |
| 31 | + h1?: string; | |
| 32 | + landmarks: string[]; | |
| 33 | + candidates: DomCandidate[]; | |
| 34 | + video_elements: { src?: string; current_src?: string; duration?: number; width: number; height: number; playing: boolean; poster?: string }[]; | |
| 35 | + text_excerpt: string; // first ~600 chars of main text | |
| 36 | + scroll: { y: number; height: number; viewport: number }; | |
| 37 | + has_login_form: boolean; | |
| 38 | + dialog_open: boolean; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const here = path.dirname(fileURLToPath(import.meta.url)); | |
| 42 | +const SNAPSHOT_SOURCE = fs | |
| 43 | + .readFileSync(path.join(here, "snapshot.page.js"), "utf8") | |
| 44 | + .replace(/^\s*\/\/.*$/gm, "") // strip comment lines so the string is a bare function expression | |
| 45 | + .trim(); | |
| 46 | + | |
| 47 | +export async function snapshotDom(page: Page, opts: { maxCandidates?: number } = {}): Promise<DomSnapshot> { | |
| 48 | + const max = opts.maxCandidates ?? 250; | |
| 49 | + return (await page.evaluate(`(${SNAPSHOT_SOURCE})(${max})`)) as DomSnapshot; | |
| 50 | +} | |
added
packages/observers/src/dom/snapshot.page.js
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +// In-page DOM snapshot. Kept as plain JavaScript (not TS) so no bundler helper (`__name`) leaks into the page. | |
| 2 | +// Evaluated by PageSummarizer.ts as `(SOURCE)(MAX)`; must remain a single function expression. | |
| 3 | +(MAX) => { | |
| 4 | + const clean = (s) => (s ?? "").replace(/\s+/g, " ").trim(); | |
| 5 | + const vh = window.innerHeight; | |
| 6 | + const regionOf = (node) => { | |
| 7 | + let el = node; | |
| 8 | + let hops = 0; | |
| 9 | + while (el && hops++ < 30) { | |
| 10 | + const role = el.getAttribute("role") ?? ""; | |
| 11 | + const tag = el.tagName.toLowerCase(); | |
| 12 | + if (role === "dialog" || el.getAttribute("aria-modal") === "true") return "dialog"; | |
| 13 | + if (tag === "ytd-comments" || /comment/i.test(el.id) || role === "comment") return "comments"; | |
| 14 | + if (role === "feed") return "feed"; | |
| 15 | + if (role === "main" || tag === "main" || el.id === "primary" || el.id === "contents" || el.id === "main-content") return "main"; | |
| 16 | + if (role === "navigation" || tag === "nav" || el.id === "guide") return "navigation"; | |
| 17 | + if (role === "complementary" || tag === "aside" || el.id === "secondary" || el.id === "related") return "sidebar"; | |
| 18 | + if (tag === "header" || role === "banner" || el.id === "masthead") return "header"; | |
| 19 | + el = el.parentElement; | |
| 20 | + } | |
| 21 | + return "unknown"; | |
| 22 | + }; | |
| 23 | + const cssPath = (el) => { | |
| 24 | + const href = el.getAttribute("href"); | |
| 25 | + const tag = el.tagName.toLowerCase(); | |
| 26 | + if (href) return `${tag}[href="${href.replace(/"/g, '\\"')}"]`; | |
| 27 | + const aria = el.getAttribute("aria-label"); | |
| 28 | + if (aria) return `${tag}[aria-label="${aria.replace(/"/g, '\\"').slice(0, 80)}"]`; | |
| 29 | + if (el.id) return `#${CSS.escape(el.id)}`; | |
| 30 | + const parts = []; | |
| 31 | + let cur = el; | |
| 32 | + let hops = 0; | |
| 33 | + while (cur && hops++ < 6 && cur !== document.body) { | |
| 34 | + const p = cur.parentElement; | |
| 35 | + const idx = p ? Array.from(p.children).indexOf(cur) + 1 : 1; | |
| 36 | + parts.unshift(`${cur.tagName.toLowerCase()}:nth-child(${idx})`); | |
| 37 | + cur = p; | |
| 38 | + } | |
| 39 | + return parts.join(">"); | |
| 40 | + }; | |
| 41 | + const isVisible = (el) => { | |
| 42 | + const r = el.getBoundingClientRect(); | |
| 43 | + const st = getComputedStyle(el); | |
| 44 | + return r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none"; | |
| 45 | + }; | |
| 46 | + const cardMeta = (el) => { | |
| 47 | + let card = el; | |
| 48 | + let hops = 0; | |
| 49 | + const isCard = (n) => n.tagName === "ARTICLE" || n.getAttribute("role") === "article" || n.tagName === "LI" || /-(renderer|item|lockup|post)$|^shreddit-post$|view-model$/i.test(n.tagName) || /(^|\s)(card|post|feed-item|lockup)(\s|$)/i.test(typeof n.className === "string" ? n.className : ""); | |
| 50 | + while (card && hops++ < 6 && !isCard(card)) card = card.parentElement; | |
| 51 | + // Only trust a card scope of bounded size; a huge container would bleed another item's numbers into this one. | |
| 52 | + const scope = card && clean(card.textContent).length < 1500 ? card : el; | |
| 53 | + const text = clean(scope.textContent).slice(0, 1200); | |
| 54 | + const out = {}; | |
| 55 | + const dur = text.match(/(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?=\s|$)/); | |
| 56 | + if (dur) out.duration = dur[1]; | |
| 57 | + const views = text.match(/([\d.,\s]+\s?[kKmMbB]?)\s*(vues|views|visionnements|upvotes|points|votes|likes|j'aime)/i); | |
| 58 | + if (views) out.views = views[0]; | |
| 59 | + const comments = text.match(/([\d.,\s]+\s?[kKmMbB]?)\s*(comment|commentaire|repl|répon)/i); | |
| 60 | + if (comments) out.comments = comments[0]; | |
| 61 | + const ago = text.match(/(il y a\s+[\w\s]+?|\d+\s+(seconds?|minutes?|hours?|days?|weeks?|months?|years?|h|min|j|sem|mois|ans?)\s+ago|\b\d+\s*(h|min|j|d|w|mo|y)\b)/i); | |
| 62 | + if (ago) out.published = ago[0]; | |
| 63 | + const authorLink = scope.querySelector('a[href*="/@"], a[href^="/user/"], a[href^="/u/"], a[href*="/channel/"], a[href^="/c/"], a[href*="/r/"]'); | |
| 64 | + if (authorLink && authorLink !== el) out.author = clean(authorLink.textContent) || (authorLink.getAttribute("href") ?? ""); | |
| 65 | + return out; | |
| 66 | + }; | |
| 67 | + | |
| 68 | + const seen = new Set(); | |
| 69 | + const candidates = []; | |
| 70 | + let index = 0; | |
| 71 | + const push = (c) => { | |
| 72 | + const key = c.kind + "|" + (c.href ?? "") + "|" + c.text.slice(0, 80); | |
| 73 | + if (seen.has(key)) return; | |
| 74 | + seen.add(key); | |
| 75 | + candidates.push({ ...c, index: index++ }); | |
| 76 | + }; | |
| 77 | + | |
| 78 | + for (const a of Array.from(document.querySelectorAll("a[href]"))) { | |
| 79 | + if (candidates.length >= MAX) break; | |
| 80 | + if (!isVisible(a)) continue; | |
| 81 | + const href = a.href; | |
| 82 | + if (!href || href.startsWith("javascript:")) continue; | |
| 83 | + const img = a.querySelector("img"); | |
| 84 | + const text = clean(a.textContent) || clean(a.getAttribute("aria-label")) || clean(a.getAttribute("title")) || clean(img ? img.alt : ""); | |
| 85 | + if (!text && !/watch|shorts|comments|status|reel|video|\/p\//.test(href)) continue; | |
| 86 | + const r = a.getBoundingClientRect(); | |
| 87 | + push({ kind: "link", href, text: text.slice(0, 200), aria_label: clean(a.getAttribute("aria-label")) || undefined, role: a.getAttribute("role") ?? undefined, region: regionOf(a), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), meta: cardMeta(a), locator_hint: cssPath(a) }); | |
| 88 | + } | |
| 89 | + for (const art of Array.from(document.querySelectorAll("article,[role=article],shreddit-post,ytd-rich-item-renderer,ytd-video-renderer,ytd-compact-video-renderer,ytd-reel-item-renderer,ytd-comment-thread-renderer"))) { | |
| 90 | + if (candidates.length >= MAX) break; | |
| 91 | + if (!isVisible(art)) continue; | |
| 92 | + const r = art.getBoundingClientRect(); | |
| 93 | + const link = art.querySelector("a[href]"); | |
| 94 | + push({ kind: "article", href: link ? link.href : undefined, text: clean(art.textContent).slice(0, 300), role: art.getAttribute("role") ?? art.tagName.toLowerCase(), region: regionOf(art), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), meta: cardMeta(art), locator_hint: cssPath(link ?? art) }); | |
| 95 | + } | |
| 96 | + for (const h of Array.from(document.querySelectorAll("h1,h2,[role=heading]")).slice(0, 30)) { | |
| 97 | + if (!isVisible(h)) continue; | |
| 98 | + const r = h.getBoundingClientRect(); | |
| 99 | + const text = clean(h.textContent); | |
| 100 | + if (!text) continue; | |
| 101 | + push({ kind: "heading", text: text.slice(0, 200), region: regionOf(h), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(h) }); | |
| 102 | + } | |
| 103 | + for (const s of Array.from(document.querySelectorAll('input[type=search],input[name*=search i],input[placeholder*=search i],input[placeholder*=recherch i],[role=searchbox],[role=combobox][aria-label*=search i],[role=combobox][aria-label*=recherch i]'))) { | |
| 104 | + if (!isVisible(s)) continue; | |
| 105 | + const r = s.getBoundingClientRect(); | |
| 106 | + push({ kind: "search", text: clean(s.getAttribute("placeholder") ?? s.getAttribute("aria-label")) || "search", region: regionOf(s), visible: r.top < vh, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(s) }); | |
| 107 | + } | |
| 108 | + for (const b of Array.from(document.querySelectorAll("button,[role=button],summary,[aria-expanded]"))) { | |
| 109 | + if (candidates.length >= MAX) break; | |
| 110 | + if (!isVisible(b)) continue; | |
| 111 | + const label = clean(b.getAttribute("aria-label")) || clean(b.textContent); | |
| 112 | + if (!/more|plus|show|afficher|voir|replies|réponses|comments|commentaires|expand|load|charger|read more|lire la suite|\.\.\./i.test(label)) continue; | |
| 113 | + const r = b.getBoundingClientRect(); | |
| 114 | + push({ kind: "button", text: label.slice(0, 120), aria_label: clean(b.getAttribute("aria-label")) || undefined, role: b.getAttribute("role") ?? "button", region: regionOf(b), visible: r.top < vh && r.bottom > 0, top: Math.round(r.top + window.scrollY), locator_hint: cssPath(b) }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + const videos = Array.from(document.querySelectorAll("video")).map((v) => ({ | |
| 118 | + src: v.getAttribute("src") ?? undefined, | |
| 119 | + current_src: v.currentSrc || undefined, | |
| 120 | + duration: Number.isFinite(v.duration) ? v.duration : undefined, | |
| 121 | + width: v.videoWidth, | |
| 122 | + height: v.videoHeight, | |
| 123 | + playing: !v.paused && !v.ended && v.readyState > 2, | |
| 124 | + poster: v.poster || undefined, | |
| 125 | + })); | |
| 126 | + | |
| 127 | + const main = document.querySelector("main,[role=main],#primary,#main-content,#contents") ?? document.body; | |
| 128 | + const landmarks = Array.from(document.querySelectorAll("[role],main,nav,aside,header,footer,form")).map((e) => e.getAttribute("role") ?? e.tagName.toLowerCase()); | |
| 129 | + const h1 = document.querySelector("h1"); | |
| 130 | + return { | |
| 131 | + url: location.href, | |
| 132 | + title: document.title, | |
| 133 | + lang: document.documentElement.lang || undefined, | |
| 134 | + h1: clean(h1 ? h1.textContent : "") || undefined, | |
| 135 | + landmarks: Array.from(new Set(landmarks)).slice(0, 25), | |
| 136 | + candidates, | |
| 137 | + video_elements: videos, | |
| 138 | + text_excerpt: clean(main.textContent).slice(0, 600), | |
| 139 | + scroll: { y: window.scrollY, height: document.documentElement.scrollHeight, viewport: vh }, | |
| 140 | + has_login_form: !!document.querySelector("input[type=password]"), | |
| 141 | + dialog_open: !!document.querySelector("[role=dialog]:not([aria-hidden=true]),[aria-modal=true]"), | |
| 142 | + }; | |
| 143 | +} | |
added
packages/observers/src/index.ts
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +export * from "./network/SchemaProfiler.ts"; | |
| 2 | +export * from "./network/ResponseClassifier.ts"; | |
| 3 | +export * from "./network/NetworkObserver.ts"; | |
| 4 | +export * from "./dom/DomObserver.ts"; | |
| 5 | +export * from "./dom/PageSummarizer.ts"; | |
| 6 | +export * from "./PageClassifier.ts"; | |
added
packages/observers/src/network/NetworkObserver.ts
+181 −0
@@ -0,0 +1,181 @@ | ||
| 1 | +import type { Page, Response, WebSocket } from "playwright"; | |
| 2 | +import { createLogger, newId, nowIso, type Platform } from "@src/shared"; | |
| 3 | +import type { EventBus } from "@src/events"; | |
| 4 | +import { classifyResponse, type CapturedResponse, type ClassifiedResponse, type ClassifierHints } from "./ResponseClassifier.ts"; | |
| 5 | + | |
| 6 | +const log = createLogger("network"); | |
| 7 | + | |
| 8 | +const MAX_BODY = 3 * 1024 * 1024; // 3 MB per JSON body | |
| 9 | +const IGNORED_HOST = /doubleclick|googlesyndication|google-analytics|googletagmanager|facebook\.com\/tr|scorecardresearch|sentry|datadog|hotjar|adservice|\/log_event|\/ptracking|\/csi_204|\/generate_204|\/youtubei\/v1\/log|\/api\/stats/i; | |
| 10 | + | |
| 11 | +export interface NetworkObserverOptions { | |
| 12 | + page: Page; | |
| 13 | + bus: EventBus; | |
| 14 | + sessionId: string; | |
| 15 | + platform: Platform; | |
| 16 | + hints: ClassifierHints; | |
| 17 | + keepBodies?: boolean; // keep JSON bodies in memory for the current step | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * NetworkObserver (§10): hooks Playwright response/websocket events, captures JSON/GraphQL/media | |
| 22 | + * responses and runs them through the classifier. Results are buffered per step and published on the bus. | |
| 23 | + */ | |
| 24 | +export class NetworkObserver { | |
| 25 | + private stepBuffer: ClassifiedResponse[] = []; | |
| 26 | + private currentStep = 0; | |
| 27 | + private currentActionId?: string; | |
| 28 | + private detached: (() => void)[] = []; | |
| 29 | + private inflight = new Set<Promise<void>>(); | |
| 30 | + readonly seenShapeHashes = new Map<string, number>(); | |
| 31 | + public totalResponses = 0; | |
| 32 | + | |
| 33 | + constructor(private readonly opts: NetworkObserverOptions) {} | |
| 34 | + | |
| 35 | + attach(): void { | |
| 36 | + const { page } = this.opts; | |
| 37 | + const onResponse = (res: Response) => { | |
| 38 | + const p = this.handleResponse(res).catch((e) => log.debug("response handling failed", { err: (e as Error).message })); | |
| 39 | + this.inflight.add(p); | |
| 40 | + p.finally(() => this.inflight.delete(p)); | |
| 41 | + }; | |
| 42 | + const onWs = (ws: WebSocket) => this.handleWebSocket(ws); | |
| 43 | + page.on("response", onResponse); | |
| 44 | + page.on("websocket", onWs); | |
| 45 | + this.detached.push(() => page.off("response", onResponse), () => page.off("websocket", onWs)); | |
| 46 | + } | |
| 47 | + | |
| 48 | + detach(): void { | |
| 49 | + for (const d of this.detached) d(); | |
| 50 | + this.detached = []; | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** Called by the engine before each action. */ | |
| 54 | + beginStep(step: number, actionId?: string): void { | |
| 55 | + this.currentStep = step; | |
| 56 | + this.currentActionId = actionId; | |
| 57 | + this.stepBuffer = []; | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** Wait for in-flight bodies then return what this step produced. */ | |
| 61 | + async collectStep(): Promise<ClassifiedResponse[]> { | |
| 62 | + await Promise.allSettled([...this.inflight]); | |
| 63 | + return [...this.stepBuffer]; | |
| 64 | + } | |
| 65 | + | |
| 66 | + private async handleResponse(res: Response): Promise<void> { | |
| 67 | + const req = res.request(); | |
| 68 | + const url = res.url(); | |
| 69 | + if (IGNORED_HOST.test(url)) return; | |
| 70 | + const resourceType = req.resourceType(); | |
| 71 | + if (["font", "stylesheet", "script"].includes(resourceType)) return; | |
| 72 | + const headers = res.headers(); | |
| 73 | + const ct = headers["content-type"] ?? ""; | |
| 74 | + const isJsonLike = /json|javascript|text\/plain/.test(ct) && ["xhr", "fetch", "other"].includes(resourceType); | |
| 75 | + const isMedia = /video|audio|mpegurl|dash/.test(ct) || /videoplayback|\.m3u8|\.mpd/.test(url); | |
| 76 | + const isFragment = /text\/html/.test(ct) && resourceType !== "document"; | |
| 77 | + if (!isJsonLike && !isMedia && !isFragment && resourceType !== "image") return; | |
| 78 | + if (resourceType === "image") { | |
| 79 | + // Only count images from media CDNs (thumbnails) — keep metadata only. | |
| 80 | + if (!/ytimg|redd\.it|redditmedia|fbcdn|cdninstagram|tiktokcdn|twimg|licdn/i.test(url)) return; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const captured: CapturedResponse = { | |
| 84 | + request_id: newId("req"), | |
| 85 | + url, | |
| 86 | + method: req.method(), | |
| 87 | + status: res.status(), | |
| 88 | + content_type: ct, | |
| 89 | + resource_type: resourceType, | |
| 90 | + body_size: Number(headers["content-length"] ?? 0), | |
| 91 | + post_data: req.postData()?.slice(0, 4000) ?? undefined, | |
| 92 | + captured_at: nowIso(), | |
| 93 | + step: this.currentStep, | |
| 94 | + }; | |
| 95 | + if (isJsonLike || isFragment) { | |
| 96 | + try { | |
| 97 | + const buf = await res.body(); | |
| 98 | + captured.body_size = buf.length; | |
| 99 | + if (buf.length <= MAX_BODY) captured.body = buf.toString("utf8"); | |
| 100 | + } catch { | |
| 101 | + // body may be unavailable (redirects, preflight) — keep metadata only | |
| 102 | + } | |
| 103 | + } | |
| 104 | + this.totalResponses++; | |
| 105 | + const classified = classifyResponse(captured, this.opts.hints); | |
| 106 | + if (classified.kind === "other" && !captured.body) return; | |
| 107 | + this.stepBuffer.push(classified); | |
| 108 | + | |
| 109 | + const fp = classified.fingerprint; | |
| 110 | + const firstSeen = !this.seenShapeHashes.has(fp.response_shape_hash); | |
| 111 | + this.seenShapeHashes.set(fp.response_shape_hash, (this.seenShapeHashes.get(fp.response_shape_hash) ?? 0) + 1); | |
| 112 | + | |
| 113 | + this.opts.bus.emit({ | |
| 114 | + event_type: "NETWORK_RESPONSE_OBSERVED", | |
| 115 | + platform: this.opts.platform, | |
| 116 | + session_id: this.opts.sessionId, | |
| 117 | + step: this.currentStep, | |
| 118 | + payload: { | |
| 119 | + request_id: captured.request_id, | |
| 120 | + url: captured.url, | |
| 121 | + method: captured.method, | |
| 122 | + status: captured.status, | |
| 123 | + kind: classified.kind, | |
| 124 | + content_type: fp.content_type, | |
| 125 | + body_size: captured.body_size, | |
| 126 | + shape_hash: fp.response_shape_hash, | |
| 127 | + hostname: fp.hostname, | |
| 128 | + path_pattern: fp.path_pattern, | |
| 129 | + graphql_operation: fp.graphql_operation, | |
| 130 | + entity_count: classified.entities.length, | |
| 131 | + entity_types: fp.observed_entity_types, | |
| 132 | + confidence: classified.confidence, | |
| 133 | + }, | |
| 134 | + provenance: [{ surface: "network", confidence: classified.confidence }], | |
| 135 | + discovered_via: { action_id: this.currentActionId }, | |
| 136 | + }); | |
| 137 | + | |
| 138 | + if (firstSeen && classified.schema && (classified.entities.length > 0 || classified.schema.candidate_entity_types.length > 0)) { | |
| 139 | + this.opts.bus.emit({ | |
| 140 | + event_type: "NETWORK_SCHEMA_DISCOVERED", | |
| 141 | + platform: this.opts.platform, | |
| 142 | + session_id: this.opts.sessionId, | |
| 143 | + step: this.currentStep, | |
| 144 | + payload: { | |
| 145 | + fingerprint: fp, | |
| 146 | + schema: { | |
| 147 | + shape_hash: classified.schema.shape_hash, | |
| 148 | + root_type: classified.schema.root_type, | |
| 149 | + repeated_object_paths: classified.schema.repeated_object_paths, | |
| 150 | + candidate_entity_types: classified.schema.candidate_entity_types, | |
| 151 | + object_count: classified.schema.object_count, | |
| 152 | + fields: classified.schema.fields | |
| 153 | + .filter((f) => f.semantic[0]?.kind !== "unknown") | |
| 154 | + .slice(0, 80) | |
| 155 | + .map((f) => ({ path: f.path, semantic: f.semantic[0], types: f.types, example: f.examples[0] })), | |
| 156 | + }, | |
| 157 | + sample_url: captured.url, | |
| 158 | + }, | |
| 159 | + provenance: [{ surface: "network", confidence: classified.confidence }], | |
| 160 | + }); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + private handleWebSocket(ws: WebSocket): void { | |
| 165 | + const url = ws.url(); | |
| 166 | + let frames = 0; | |
| 167 | + ws.on("framereceived", (frame) => { | |
| 168 | + frames++; | |
| 169 | + if (frames > 200) return; // don't flood the log | |
| 170 | + const payload = typeof frame.payload === "string" ? frame.payload : frame.payload.toString("utf8"); | |
| 171 | + this.opts.bus.emit({ | |
| 172 | + event_type: "WEBSOCKET_FRAME_OBSERVED", | |
| 173 | + platform: this.opts.platform, | |
| 174 | + session_id: this.opts.sessionId, | |
| 175 | + step: this.currentStep, | |
| 176 | + payload: { url, size: payload.length, looks_json: /^\s*[[{]/.test(payload), preview: payload.slice(0, 200) }, | |
| 177 | + provenance: [{ surface: "network", confidence: 0.6 }], | |
| 178 | + }); | |
| 179 | + }); | |
| 180 | + } | |
| 181 | +} | |
added
packages/observers/src/network/ResponseClassifier.ts
+264 −0
@@ -0,0 +1,264 @@ | ||
| 1 | +import { | |
| 2 | + canonicalUrl, | |
| 3 | + parseCount, | |
| 4 | + parseDuration, | |
| 5 | + shortHash, | |
| 6 | + truncate, | |
| 7 | + walkJson, | |
| 8 | + type EntityType, | |
| 9 | + type Evidenced, | |
| 10 | + type NetworkFingerprint, | |
| 11 | + type ObservedEntity, | |
| 12 | + type Platform, | |
| 13 | + type Provenance, | |
| 14 | + type SchemaProfile, | |
| 15 | +} from "@src/shared"; | |
| 16 | +import { profileJson } from "./SchemaProfiler.ts"; | |
| 17 | + | |
| 18 | +export interface CapturedResponse { | |
| 19 | + request_id: string; | |
| 20 | + url: string; | |
| 21 | + method: string; | |
| 22 | + status: number; | |
| 23 | + content_type: string; | |
| 24 | + resource_type: string; | |
| 25 | + body_size: number; | |
| 26 | + body?: string; // only kept for JSON / GraphQL / small HTML fragments | |
| 27 | + post_data?: string; | |
| 28 | + timing_ms?: number; | |
| 29 | + captured_at: string; | |
| 30 | + step?: number; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface ClassifiedResponse { | |
| 34 | + response: CapturedResponse; | |
| 35 | + kind: "json" | "graphql" | "html_fragment" | "media_manifest" | "media_segment" | "image" | "other"; | |
| 36 | + fingerprint: NetworkFingerprint; | |
| 37 | + schema?: SchemaProfile; | |
| 38 | + entities: ObservedEntity[]; // generic candidate entities mined from the payload | |
| 39 | + confidence: number; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** Text collapsers let adapters describe how a platform nests rich text (e.g. YouTube `runs`/`simpleText`). */ | |
| 43 | +export type TextCollapser = (v: unknown) => string | undefined; | |
| 44 | + | |
| 45 | +export interface ClassifierHints { | |
| 46 | + platform: Platform; | |
| 47 | + textCollapsers?: TextCollapser[]; | |
| 48 | + /** Key names (case-insensitive substrings) that strongly denote an entity id on this platform. */ | |
| 49 | + idKeys?: Record<string, EntityType>; | |
| 50 | + /** Build a canonical page URL from a platform id. */ | |
| 51 | + urlForId?: (type: EntityType, id: string) => string | undefined; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export function pathPattern(url: string): { hostname: string; path_pattern: string } { | |
| 55 | + try { | |
| 56 | + const u = new URL(url); | |
| 57 | + const path = u.pathname | |
| 58 | + .split("/") | |
| 59 | + .map((seg) => (/^\d+$/.test(seg) || /^[A-Za-z0-9_-]{11,}$/.test(seg) || /^t[0-9]_[a-z0-9]+$/.test(seg) ? "*" : seg)) | |
| 60 | + .join("/"); | |
| 61 | + return { hostname: u.hostname, path_pattern: path || "/" }; | |
| 62 | + } catch { | |
| 63 | + return { hostname: "", path_pattern: url }; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +export function classifyKind(r: CapturedResponse): ClassifiedResponse["kind"] { | |
| 68 | + const ct = r.content_type.toLowerCase(); | |
| 69 | + const u = r.url.toLowerCase(); | |
| 70 | + if (/\.m3u8|\.mpd|mpegurl|dash\+xml|manifest/.test(u + " " + ct)) return "media_manifest"; | |
| 71 | + if (/videoplayback|\.ts(\?|$)|\.m4s|\.mp4|video\/|audio\//.test(u + " " + ct)) return "media_segment"; | |
| 72 | + if (ct.startsWith("image/")) return "image"; | |
| 73 | + if (ct.includes("json") || (r.body && /^\s*[[{]/.test(r.body))) { | |
| 74 | + if (/graphql/i.test(u) || (r.post_data && /"query"\s*:|operationName|doc_id|fb_api_req_friendly_name/.test(r.post_data))) return "graphql"; | |
| 75 | + return "json"; | |
| 76 | + } | |
| 77 | + if (ct.includes("text/html") && r.resource_type !== "document") return "html_fragment"; | |
| 78 | + return "other"; | |
| 79 | +} | |
| 80 | + | |
| 81 | +function graphqlOperation(r: CapturedResponse): string | undefined { | |
| 82 | + if (!r.post_data) return undefined; | |
| 83 | + const m = r.post_data.match(/(?:"operationName"\s*:\s*"|fb_api_req_friendly_name=)([A-Za-z0-9_]+)/); | |
| 84 | + return m?.[1]; | |
| 85 | +} | |
| 86 | + | |
| 87 | +function looseJson(body: string): unknown | undefined { | |
| 88 | + // Some platforms prefix JSON with anti-hijack tokens (")]}'", "for (;;);") or send NDJSON. | |
| 89 | + const cleaned = body.replace(/^\)\]\}'\s*/, "").replace(/^for \(;;\);/, "").trim(); | |
| 90 | + try { | |
| 91 | + return JSON.parse(cleaned); | |
| 92 | + } catch { | |
| 93 | + const lines = cleaned.split("\n").filter((l) => l.trim().startsWith("{")); | |
| 94 | + if (lines.length > 1) { | |
| 95 | + const parsed = lines.map((l) => { | |
| 96 | + try { | |
| 97 | + return JSON.parse(l); | |
| 98 | + } catch { | |
| 99 | + return undefined; | |
| 100 | + } | |
| 101 | + }).filter(Boolean); | |
| 102 | + return parsed.length ? parsed : undefined; | |
| 103 | + } | |
| 104 | + return undefined; | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * Generic JSON entity miner: find objects that carry an id-like field plus some | |
| 110 | + * displayable content, without knowing the platform's property names. | |
| 111 | + */ | |
| 112 | +export function mineEntities(root: unknown, hints: ClassifierHints, step?: number): ObservedEntity[] { | |
| 113 | + const collapsers = hints.textCollapsers ?? []; | |
| 114 | + const collapse = (v: unknown): string | undefined => { | |
| 115 | + if (typeof v === "string") return v; | |
| 116 | + if (typeof v === "number") return String(v); | |
| 117 | + for (const c of collapsers) { | |
| 118 | + const s = c(v); | |
| 119 | + if (s) return s; | |
| 120 | + } | |
| 121 | + return undefined; | |
| 122 | + }; | |
| 123 | + const idKeyEntries = Object.entries(hints.idKeys ?? {}).map(([k, t]) => [k.toLowerCase(), t] as const); | |
| 124 | + const found = new Map<string, ObservedEntity>(); | |
| 125 | + let refCounter = 0; | |
| 126 | + | |
| 127 | + walkJson(root, (v, path) => { | |
| 128 | + if (!v || typeof v !== "object" || Array.isArray(v)) return; | |
| 129 | + const obj = v as Record<string, unknown>; | |
| 130 | + const keys = Object.keys(obj); | |
| 131 | + // 1) find an id | |
| 132 | + let idKey: string | undefined; | |
| 133 | + let idType: EntityType | undefined; | |
| 134 | + for (const k of keys) { | |
| 135 | + const kl = k.toLowerCase(); | |
| 136 | + const hint = idKeyEntries.find(([hk]) => kl === hk); | |
| 137 | + if (hint && (typeof obj[k] === "string" || typeof obj[k] === "number")) { | |
| 138 | + idKey = k; | |
| 139 | + idType = hint[1]; | |
| 140 | + break; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + if (!idKey) { | |
| 144 | + for (const k of keys) { | |
| 145 | + if (/(^id$|Id$|_id$|^videoId$|^channelId$|^postId$|^name$)/.test(k) && k !== "name" && typeof obj[k] === "string" && /^[A-Za-z0-9_-]{4,64}$/.test(obj[k] as string)) { | |
| 146 | + idKey = k; | |
| 147 | + break; | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + if (!idKey) return; | |
| 152 | + const id = String(obj[idKey]); | |
| 153 | + | |
| 154 | + // 2) displayable content nearby (shallow) | |
| 155 | + const pick = (re: RegExp) => { | |
| 156 | + for (const k of keys) if (re.test(k)) { | |
| 157 | + const s = collapse(obj[k]); | |
| 158 | + if (s && s.length > 0) return s; | |
| 159 | + } | |
| 160 | + return undefined; | |
| 161 | + }; | |
| 162 | + const title = pick(/^(title|headline|name|displayName|fullName|display_name)$/i); | |
| 163 | + const text = pick(/^(text|body|caption|description|selftext|descriptionSnippet|content|message)$/i); | |
| 164 | + const author = pick(/^(author|ownerText|shortBylineText|longBylineText|author_name|username|channelName|user_name|screen_name|handle)$/i); | |
| 165 | + const thumb = (() => { | |
| 166 | + let out: string | undefined; | |
| 167 | + walkJson(obj, (x, p) => { | |
| 168 | + if (out) return false; | |
| 169 | + if (p.length > 4) return false; | |
| 170 | + if (typeof x === "string" && /^https?:\/\/.*(\.(jpe?g|png|webp)|ytimg|thumbnail|preview|avatar)/i.test(x) && /(thumb|image|img|picture|avatar|preview|url)/i.test(p.join("."))) out = x; | |
| 171 | + }); | |
| 172 | + return out; | |
| 173 | + })(); | |
| 174 | + const durationText = pick(/^(lengthText|duration|length|durationText|video_duration)$/i); | |
| 175 | + const viewsText = pick(/^(viewCountText|shortViewCountText|views|view_count|viewCount|ups|score|num_comments|likeCount|like_count)$/i); | |
| 176 | + | |
| 177 | + if (!title && !text && !idType) return; // an id alone is not an entity | |
| 178 | + | |
| 179 | + // 3) type guess | |
| 180 | + let type: EntityType = idType ?? "post"; | |
| 181 | + if (!idType) { | |
| 182 | + if (/video/i.test(idKey) || durationText || /video|shorts|watch/i.test(keys.join(" "))) type = "video"; | |
| 183 | + else if (/channel|user|author|owner|profile/i.test(idKey) || (title && !text && /subscriber|follower|handle|username/i.test(keys.join(" ")))) type = "profile"; | |
| 184 | + else if (/comment|reply/i.test(idKey) || /parent_id|replyCount|depth/i.test(keys.join(" "))) type = "comment"; | |
| 185 | + } | |
| 186 | + | |
| 187 | + const url = hints.urlForId?.(type, id) ?? (pick(/^(url|permalink|canonicalUrl|href|link)$/i) ?? undefined); | |
| 188 | + const fingerprint = `${hints.platform}:${type}:${id}`; | |
| 189 | + if (found.has(fingerprint)) return; | |
| 190 | + | |
| 191 | + const prov: Provenance[] = [{ surface: "network", confidence: idType ? 0.95 : 0.7, detail: path.join(".") }]; | |
| 192 | + const ev = (value: unknown): Evidenced => ({ value, provenance: prov }); | |
| 193 | + const fields: Record<string, Evidenced> = { platform_id: ev(id) }; | |
| 194 | + if (title) fields.title = ev(title); | |
| 195 | + if (text) fields.text = ev(text); | |
| 196 | + if (author) fields.author = ev(author); | |
| 197 | + if (thumb) fields.thumbnail_url = ev(thumb); | |
| 198 | + if (durationText) fields.duration = ev(durationText); | |
| 199 | + if (viewsText) fields.views_text = ev(viewsText); | |
| 200 | + | |
| 201 | + const metrics: ObservedEntity["metrics"] = {}; | |
| 202 | + const views = parseCount(viewsText); | |
| 203 | + if (views !== undefined) { | |
| 204 | + if (type === "video") metrics.views = views; | |
| 205 | + else metrics.score = views; | |
| 206 | + } | |
| 207 | + | |
| 208 | + found.set(fingerprint, { | |
| 209 | + ref: `N${++refCounter}`, | |
| 210 | + type, | |
| 211 | + platform: hints.platform, | |
| 212 | + platform_id: id, | |
| 213 | + url: url ? canonicalUrl(url.startsWith("http") ? url : `https://${hints.platform === "x" ? "x.com" : hints.platform + ".com"}${url}`) : undefined, | |
| 214 | + name: title ? truncate(title, 200) : undefined, | |
| 215 | + text: text ? truncate(text, 500) : undefined, | |
| 216 | + author: author ? truncate(author, 120) : undefined, | |
| 217 | + metrics: Object.keys(metrics).length ? metrics : undefined, | |
| 218 | + media: type === "video" ? { has_video: true, has_image: !!thumb, duration_s: parseDuration(durationText), thumbnail_url: thumb } : thumb ? { has_video: false, has_image: true, thumbnail_url: thumb } : undefined, | |
| 219 | + context: `network ${path.slice(0, 4).join(".")}`, | |
| 220 | + fields, | |
| 221 | + provenance: prov, | |
| 222 | + fingerprint, | |
| 223 | + }); | |
| 224 | + if (found.size >= 400) return false; | |
| 225 | + }); | |
| 226 | + | |
| 227 | + return [...found.values()]; | |
| 228 | +} | |
| 229 | + | |
| 230 | +export function classifyResponse(r: CapturedResponse, hints: ClassifierHints): ClassifiedResponse { | |
| 231 | + const kind = classifyKind(r); | |
| 232 | + const { hostname, path_pattern } = pathPattern(r.url); | |
| 233 | + const base: NetworkFingerprint = { | |
| 234 | + hostname, | |
| 235 | + path_pattern, | |
| 236 | + method: r.method, | |
| 237 | + content_type: r.content_type.split(";")[0] ?? "", | |
| 238 | + response_shape_hash: "", | |
| 239 | + is_graphql: kind === "graphql", | |
| 240 | + graphql_operation: kind === "graphql" ? graphqlOperation(r) : undefined, | |
| 241 | + observed_entity_types: [], | |
| 242 | + }; | |
| 243 | + let schema: SchemaProfile | undefined; | |
| 244 | + let entities: ObservedEntity[] = []; | |
| 245 | + let confidence = 0.3; | |
| 246 | + | |
| 247 | + if ((kind === "json" || kind === "graphql") && r.body) { | |
| 248 | + const parsed = looseJson(r.body); | |
| 249 | + if (parsed !== undefined) { | |
| 250 | + schema = profileJson(parsed); | |
| 251 | + base.response_shape_hash = schema.shape_hash; | |
| 252 | + entities = mineEntities(parsed, hints, r.step); | |
| 253 | + const types = new Set<EntityType>(); | |
| 254 | + for (const e of entities) types.add(e.type); | |
| 255 | + for (const c of schema.candidate_entity_types) if (c.confidence >= 0.6) types.add(c.type); | |
| 256 | + base.observed_entity_types = [...types]; | |
| 257 | + confidence = entities.length ? 0.9 : schema.candidate_entity_types.length ? 0.6 : 0.4; | |
| 258 | + } | |
| 259 | + } else { | |
| 260 | + base.response_shape_hash = shortHash(`${kind}:${hostname}:${path_pattern}:${base.content_type}`, 16); | |
| 261 | + if (kind === "media_manifest" || kind === "media_segment") confidence = 0.8; | |
| 262 | + } | |
| 263 | + return { response: r, kind, fingerprint: base, schema, entities, confidence }; | |
| 264 | +} | |
added
packages/observers/src/network/SchemaProfiler.test.ts
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { profileJson, guessFieldSemantics, shapeSignature } from "./SchemaProfiler.ts"; | |
| 3 | +import { mineEntities, classifyResponse, type CapturedResponse } from "./ResponseClassifier.ts"; | |
| 4 | +import { youtubeAdapter } from "@src/connectors"; | |
| 5 | + | |
| 6 | +// Fixture shaped like an innertube search response (property names are *not* assumed by the profiler). | |
| 7 | +const youtubeLike = { | |
| 8 | + contents: { | |
| 9 | + sectionList: { | |
| 10 | + items: [ | |
| 11 | + { videoRenderer: { videoId: "dQw4w9WgXcQ", title: { runs: [{ text: "IA au Québec — table ronde" }] }, ownerText: { runs: [{ text: "Chaîne Exemple" }] }, lengthText: { simpleText: "12:34" }, viewCountText: { simpleText: "1,2 M de vues" }, thumbnail: { thumbnails: [{ url: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", width: 480, height: 360 }] } } }, | |
| 12 | + { videoRenderer: { videoId: "abcdefghijk", title: { runs: [{ text: "Montréal AI meetup" }] }, ownerText: { runs: [{ text: "Autre Chaîne" }] }, lengthText: { simpleText: "1:02:03" }, viewCountText: { simpleText: "48 k vues" }, thumbnail: { thumbnails: [{ url: "https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg", width: 480, height: 360 }] } } }, | |
| 13 | + { channelRenderer: { channelId: "UC1234567890abcdefghij", title: { simpleText: "Québec IA" }, subscriberCountText: { simpleText: "12 k abonnés" } } }, | |
| 14 | + ], | |
| 15 | + }, | |
| 16 | + continuation: { token: "EpMBEgVoZWxsbxqJAVNCU0NBUXFsY1ZvVFVIZG9kMlV5UmtsbGRuZG1kMkkyYjBOaGRIZG9UbGxSVUZGVmxWMFVrMWFTV2xhVjFwWGRGUlY" }, | |
| 17 | + }, | |
| 18 | +}; | |
| 19 | + | |
| 20 | +describe("SchemaProfiler", () => { | |
| 21 | + it("infers field semantics from value shapes and key names", () => { | |
| 22 | + expect(guessFieldSemantics("videoId", ["dQw4w9WgXcQ", "abcdefghijk"])[0]!.kind).toBe("identifier"); | |
| 23 | + expect(guessFieldSemantics("url", ["https://i.ytimg.com/vi/x/hq.jpg"])[0]!.kind).toBe("thumbnail_url"); | |
| 24 | + expect(guessFieldSemantics("simpleText", ["12:34", "1:02:03"])[0]!.kind).toBe("duration"); | |
| 25 | + expect(guessFieldSemantics("handle", ["@simon", "@bob"])[0]!.kind).toBe("username"); | |
| 26 | + expect(guessFieldSemantics("created_utc", [1700000000, 1700000500])[0]!.kind).toBe("timestamp"); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("produces a stable shape hash independent of values", () => { | |
| 30 | + const a = shapeSignature({ id: "1", name: "a", tags: ["x"] }); | |
| 31 | + const b = shapeSignature({ name: "zzz", id: "999", tags: ["y", "z"] }); | |
| 32 | + expect(a).toBe(b); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("finds repeated objects and candidate entity lists", () => { | |
| 36 | + const p = profileJson(youtubeLike); | |
| 37 | + expect(p.repeated_object_paths).toContain("contents.sectionList.items"); | |
| 38 | + expect(p.fields.some((f) => f.path.endsWith("videoId") && f.semantic[0]!.kind === "identifier")).toBe(true); | |
| 39 | + expect(p.candidate_entity_types.length).toBeGreaterThan(0); | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe("mineEntities (generic JSON entity miner)", () => { | |
| 44 | + it("extracts videos and channels with collapsed rich text and provenance", () => { | |
| 45 | + const ents = mineEntities(youtubeLike, youtubeAdapter.classifierHints); | |
| 46 | + const videos = ents.filter((e) => e.type === "video"); | |
| 47 | + const channels = ents.filter((e) => e.type === "channel"); | |
| 48 | + expect(videos).toHaveLength(2); | |
| 49 | + expect(channels).toHaveLength(1); | |
| 50 | + const v = videos.find((e) => e.platform_id === "dQw4w9WgXcQ")!; | |
| 51 | + expect(v.name).toBe("IA au Québec — table ronde"); | |
| 52 | + expect(v.author).toBe("Chaîne Exemple"); | |
| 53 | + expect(v.media?.duration_s).toBe(12 * 60 + 34); | |
| 54 | + expect(v.metrics?.views).toBe(1_200_000); | |
| 55 | + expect(v.url).toBe("https://youtube.com/watch?v=dQw4w9WgXcQ"); | |
| 56 | + expect(v.provenance[0]!.surface).toBe("network"); | |
| 57 | + expect(Object.keys(v.fields)).toContain("platform_id"); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("classifies a JSON response end-to-end", () => { | |
| 61 | + const r: CapturedResponse = { request_id: "req_1", url: "https://www.youtube.com/youtubei/v1/search?prettyPrint=false", method: "POST", status: 200, content_type: "application/json; charset=UTF-8", resource_type: "xhr", body_size: 100, body: JSON.stringify(youtubeLike), captured_at: new Date().toISOString() }; | |
| 62 | + const c = classifyResponse(r, youtubeAdapter.classifierHints); | |
| 63 | + expect(c.kind).toBe("json"); | |
| 64 | + expect(c.entities.length).toBe(3); | |
| 65 | + expect(c.fingerprint.observed_entity_types).toContain("video"); | |
| 66 | + expect(c.fingerprint.path_pattern).toBe("/youtubei/v1/search"); | |
| 67 | + expect(c.confidence).toBeGreaterThan(0.8); | |
| 68 | + }); | |
| 69 | +}); | |
added
packages/observers/src/network/SchemaProfiler.ts
+199 −0
@@ -0,0 +1,199 @@ | ||
| 1 | +import { | |
| 2 | + shortHash, | |
| 3 | + type EntityType, | |
| 4 | + type SchemaField, | |
| 5 | + type SchemaProfile, | |
| 6 | + type SemanticFieldGuess, | |
| 7 | + type SemanticFieldKind, | |
| 8 | +} from "@src/shared"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * SchemaProfiler (§11, §12): infers structure and field semantics of a JSON payload | |
| 12 | + * without assuming property names. Purely deterministic (Tier 1). | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const ID_KEY = /(^|_|\b)(id|ids|uid|guid|key|pk)$|Id$|ID$|_id$/; | |
| 16 | +const USERNAME_KEY = /(user|screen)?name$|handle|login|slug|^author$|owner/i; | |
| 17 | +const TITLE_KEY = /title|headline|subject/i; | |
| 18 | +const TEXT_KEY = /text|body|caption|description|content|message|selftext|snippet/i; | |
| 19 | +const URL_KEY = /url|href|link|permalink|uri/i; | |
| 20 | +const MEDIA_KEY = /video|stream|manifest|playback|media|mp4|hls|dash|audio/i; | |
| 21 | +const THUMB_KEY = /thumb|thumbnail|poster|preview|avatar|icon|image|img|picture|photo/i; | |
| 22 | +const TIME_KEY = /time|date|created|published|updated|_at$|timestamp|utc/i; | |
| 23 | +const COUNT_KEY = /count|views|likes|score|ups|downs|comments|shares|subscribers|followers|favorites|reposts|replies|num_/i; | |
| 24 | +const DURATION_KEY = /duration|length/i; | |
| 25 | +const CURSOR_KEY = /cursor|continuation|token|after|before|next|page_?info|offset/i; | |
| 26 | + | |
| 27 | +function typeOf(v: unknown): string { | |
| 28 | + if (v === null) return "null"; | |
| 29 | + if (Array.isArray(v)) return "array"; | |
| 30 | + return typeof v; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function guessFieldSemantics(key: string, values: unknown[]): SemanticFieldGuess[] { | |
| 34 | + const guesses: Map<SemanticFieldKind, number> = new Map(); | |
| 35 | + const add = (k: SemanticFieldKind, c: number) => guesses.set(k, Math.max(guesses.get(k) ?? 0, c)); | |
| 36 | + const strings = values.filter((v): v is string => typeof v === "string"); | |
| 37 | + const numbers = values.filter((v): v is number => typeof v === "number"); | |
| 38 | + const bools = values.filter((v) => typeof v === "boolean"); | |
| 39 | + const n = values.length || 1; | |
| 40 | + | |
| 41 | + if (bools.length / n > 0.8) add("boolean", 0.95); | |
| 42 | + | |
| 43 | + // value-shape signals | |
| 44 | + const urlRatio = strings.filter((s) => /^https?:\/\//.test(s)).length / n; | |
| 45 | + if (urlRatio > 0.7) { | |
| 46 | + add("url", 0.85); | |
| 47 | + const mediaRatio = strings.filter((s) => /\.(m3u8|mpd|mp4|webm|m4a|mp3)(\?|$)|videoplayback|\/video\//i.test(s)).length / n; | |
| 48 | + // Value evidence is more specific than the key name: a URL whose values look like images *is* a thumbnail url. | |
| 49 | + if (mediaRatio > 0.5) add("media_url", 0.93); | |
| 50 | + const thumbRatio = strings.filter((s) => /\.(jpe?g|png|webp|gif)(\?|$)|thumbnail|ytimg|preview|avatar/i.test(s)).length / n; | |
| 51 | + if (thumbRatio > 0.5) add("thumbnail_url", 0.92); | |
| 52 | + } | |
| 53 | + const handleRatio = strings.filter((s) => /^@[\w.]{2,40}$/.test(s) || /^u\/[\w-]+$/.test(s)).length / n; | |
| 54 | + if (handleRatio > 0.6) add("username", 0.9); | |
| 55 | + const isoRatio = strings.filter((s) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)).length / n; | |
| 56 | + if (isoRatio > 0.6) add("timestamp", 0.95); | |
| 57 | + const epochRatio = numbers.filter((x) => x > 1_000_000_000 && x < 4_000_000_000).length / n; | |
| 58 | + if (epochRatio > 0.6) add("timestamp", 0.75); | |
| 59 | + const durationRatio = strings.filter((s) => /^(\d+:)?\d{1,2}:\d{2}$/.test(s) || /^PT\d/.test(s)).length / n; | |
| 60 | + if (durationRatio > 0.6) add("duration", 0.9); | |
| 61 | + const idLike = strings.filter((s) => /^[A-Za-z0-9_-]{6,64}$/.test(s) && !/\s/.test(s) && !/^[a-z]+$/.test(s)).length / n; | |
| 62 | + const longText = strings.filter((s) => s.length > 60 || /\s.*\s/.test(s)).length / n; | |
| 63 | + const countLike = strings.filter((s) => /^[\d.,\s]+\s*[kKmMbB]?(\s|$)/.test(s) && /\d/.test(s) && s.length < 40).length / n; | |
| 64 | + | |
| 65 | + // key-name signals (weaker than value signals, but combine) | |
| 66 | + if (ID_KEY.test(key)) add("identifier", idLike > 0.5 ? 0.95 : numbers.length / n > 0.5 ? 0.85 : 0.6); | |
| 67 | + else if (idLike > 0.8 && strings.length === values.length) add("identifier", 0.55); | |
| 68 | + if (USERNAME_KEY.test(key) && strings.length) add(handleRatio > 0.3 ? "username" : "display_name", 0.7); | |
| 69 | + if (TITLE_KEY.test(key) && strings.length) add("title", 0.85); | |
| 70 | + if (TEXT_KEY.test(key) && strings.length) add("text", longText > 0.3 ? 0.85 : 0.6); | |
| 71 | + else if (longText > 0.7 && strings.length === values.length) add("text", 0.5); | |
| 72 | + if (URL_KEY.test(key)) add("url", urlRatio > 0.5 ? 0.9 : 0.55); | |
| 73 | + if (MEDIA_KEY.test(key) && urlRatio > 0.3) add("media_url", 0.7); | |
| 74 | + if (THUMB_KEY.test(key) && urlRatio > 0.3) add("thumbnail_url", 0.8); | |
| 75 | + if (TIME_KEY.test(key)) add("timestamp", 0.7); | |
| 76 | + if (COUNT_KEY.test(key)) add("count", numbers.length / n > 0.5 || countLike > 0.5 ? 0.9 : 0.5); | |
| 77 | + else if (numbers.length / n > 0.8 && numbers.every((x) => Number.isInteger(x) && x >= 0) && epochRatio < 0.5) add("count", 0.4); | |
| 78 | + if (DURATION_KEY.test(key)) add("duration", 0.75); | |
| 79 | + if (CURSOR_KEY.test(key) && idLike > 0.3) add("cursor", 0.75); | |
| 80 | + if (/^[A-Za-z0-9+/=_-]{80,}$/.test(strings[0] ?? "")) add("cursor", 0.6); | |
| 81 | + | |
| 82 | + if (guesses.size === 0) add("unknown", 0.3); | |
| 83 | + return [...guesses.entries()] | |
| 84 | + .map(([kind, confidence]) => ({ kind, confidence })) | |
| 85 | + .sort((a, b) => b.confidence - a.confidence) | |
| 86 | + .slice(0, 3); | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Structural shape signature: sorted keys + value types, arrays collapsed to their first element. Ids/values excluded. */ | |
| 90 | +export function shapeSignature(v: unknown, depth = 0): string { | |
| 91 | + if (depth > 6) return "…"; | |
| 92 | + if (Array.isArray(v)) return `[${v.length ? shapeSignature(v[0], depth + 1) : ""}]`; | |
| 93 | + if (v && typeof v === "object") { | |
| 94 | + const keys = Object.keys(v as object).sort().slice(0, 40); | |
| 95 | + return `{${keys.map((k) => `${k}:${shapeSignature((v as Record<string, unknown>)[k], depth + 1)}`).join(",")}}`; | |
| 96 | + } | |
| 97 | + return typeOf(v); | |
| 98 | +} | |
| 99 | + | |
| 100 | +interface Collected { | |
| 101 | + types: Set<string>; | |
| 102 | + values: unknown[]; | |
| 103 | + seen: number; | |
| 104 | +} | |
| 105 | + | |
| 106 | +/** | |
| 107 | + * Profile a JSON payload. Fields are aggregated across all objects sharing a path | |
| 108 | + * (array indices collapsed to []), so repeated objects (feed items) surface naturally. | |
| 109 | + */ | |
| 110 | +export function profileJson(root: unknown, opts: { maxNodes?: number } = {}): SchemaProfile { | |
| 111 | + const maxNodes = opts.maxNodes ?? 20_000; | |
| 112 | + const fields = new Map<string, Collected>(); | |
| 113 | + const arrayObjectCounts = new Map<string, number>(); // path → number of object elements | |
| 114 | + let objectCount = 0; | |
| 115 | + let nodes = 0; | |
| 116 | + | |
| 117 | + const visit = (v: unknown, pathParts: string[], depth: number) => { | |
| 118 | + if (nodes++ > maxNodes || depth > 30) return; | |
| 119 | + if (Array.isArray(v)) { | |
| 120 | + const objs = v.filter((x) => x && typeof x === "object" && !Array.isArray(x)).length; | |
| 121 | + if (objs >= 2) arrayObjectCounts.set(pathParts.join("."), (arrayObjectCounts.get(pathParts.join(".")) ?? 0) + objs); | |
| 122 | + for (const item of v.slice(0, 200)) visit(item, [...pathParts, "[]"], depth + 1); | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + if (v && typeof v === "object") { | |
| 126 | + objectCount++; | |
| 127 | + for (const [k, val] of Object.entries(v as Record<string, unknown>)) { | |
| 128 | + const p = [...pathParts, k].join("."); | |
| 129 | + const c = fields.get(p) ?? { types: new Set(), values: [], seen: 0 }; | |
| 130 | + c.types.add(typeOf(val)); | |
| 131 | + c.seen++; | |
| 132 | + if (c.values.length < 25 && (typeof val !== "object" || val === null)) c.values.push(val); | |
| 133 | + fields.set(p, c); | |
| 134 | + visit(val, [...pathParts, k], depth + 1); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + }; | |
| 138 | + visit(root, [], 0); | |
| 139 | + | |
| 140 | + const schemaFields: SchemaField[] = []; | |
| 141 | + for (const [p, c] of fields) { | |
| 142 | + const key = p.split(".").filter((s) => s !== "[]").pop() ?? p; | |
| 143 | + const scalarValues = c.values; | |
| 144 | + const semantic = scalarValues.length ? guessFieldSemantics(key, scalarValues) : [{ kind: "unknown" as SemanticFieldKind, confidence: 0.2 }]; | |
| 145 | + schemaFields.push({ | |
| 146 | + path: p, | |
| 147 | + types: [...c.types], | |
| 148 | + semantic, | |
| 149 | + examples: scalarValues.slice(0, 3).map((x) => String(x).slice(0, 80)), | |
| 150 | + frequency: Math.min(1, c.seen / Math.max(1, objectCount)), | |
| 151 | + }); | |
| 152 | + } | |
| 153 | + | |
| 154 | + const repeated = [...arrayObjectCounts.entries()].sort((a, b) => b[1] - a[1]).map(([p]) => p); | |
| 155 | + const candidates = detectEntityCandidates(repeated, schemaFields); | |
| 156 | + | |
| 157 | + return { | |
| 158 | + shape_hash: shortHash(shapeSignature(root), 16), | |
| 159 | + root_type: Array.isArray(root) ? "array" : root && typeof root === "object" ? "object" : "scalar", | |
| 160 | + repeated_object_paths: repeated.slice(0, 20), | |
| 161 | + fields: schemaFields.slice(0, 400), | |
| 162 | + candidate_entity_types: candidates, | |
| 163 | + object_count: objectCount, | |
| 164 | + }; | |
| 165 | +} | |
| 166 | + | |
| 167 | +/** From repeated-object arrays, guess which entity type each array holds by the semantics of its child fields. */ | |
| 168 | +function detectEntityCandidates(repeated: string[], fields: SchemaField[]): SchemaProfile["candidate_entity_types"] { | |
| 169 | + const out: SchemaProfile["candidate_entity_types"] = []; | |
| 170 | + for (const p of repeated.slice(0, 30)) { | |
| 171 | + const prefix = p ? p + ".[]." : "[]."; | |
| 172 | + const children = fields.filter((f) => f.path.startsWith(prefix)); | |
| 173 | + if (children.length === 0) continue; | |
| 174 | + const has = (kind: SemanticFieldKind, minConf = 0.5) => children.some((f) => f.semantic.some((s) => s.kind === kind && s.confidence >= minConf)); | |
| 175 | + const keyHas = (re: RegExp) => children.some((f) => re.test(f.path.slice(prefix.length))); | |
| 176 | + const id = has("identifier"); | |
| 177 | + if (!id) continue; | |
| 178 | + let type: EntityType | undefined; | |
| 179 | + let conf = 0.5; | |
| 180 | + if (keyHas(/video|duration|length|watch|views?/i) || has("duration")) { | |
| 181 | + type = "video"; | |
| 182 | + conf = 0.75; | |
| 183 | + } else if (keyHas(/channel|subscri|owner|author.*(name|url)|user_?name|handle|screen_name/i) && !has("text") && !has("title")) { | |
| 184 | + type = "profile"; | |
| 185 | + conf = 0.65; | |
| 186 | + } else if (keyHas(/comment|repl|parent_id|depth/i) && has("text")) { | |
| 187 | + type = "comment"; | |
| 188 | + conf = 0.7; | |
| 189 | + } else if (has("text") || has("title")) { | |
| 190 | + type = "post"; | |
| 191 | + conf = has("count") ? 0.75 : 0.6; | |
| 192 | + } else if (keyHas(/community|subreddit|group|page/i)) { | |
| 193 | + type = "community"; | |
| 194 | + conf = 0.55; | |
| 195 | + } | |
| 196 | + if (type) out.push({ type, confidence: conf, path: p }); | |
| 197 | + } | |
| 198 | + return out.sort((a, b) => b.confidence - a.confidence).slice(0, 10); | |
| 199 | +} | |
added
packages/platform-model/package.json
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/platform-model", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*" | |
| 14 | + } | |
| 15 | +} | |
added
packages/platform-model/src/index.ts
+254 −0
@@ -0,0 +1,254 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { clamp01, createLogger, nowIso, type EntityType, type NetworkFingerprint, type PageType, type Platform } from "@src/shared"; | |
| 4 | +import type { EventBus, SocialEvent } from "@src/events"; | |
| 5 | + | |
| 6 | +const log = createLogger("platform-model"); | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Connector Learning Engine (§29–§31, §61, §71): persists what the crawler learns about a platform | |
| 10 | + * into data/platform_model/<platform>/*.json and turns repeated observations into confidence. | |
| 11 | + */ | |
| 12 | +export interface ResponsePattern { | |
| 13 | + shape_hash: string; | |
| 14 | + hostname: string; | |
| 15 | + path_pattern: string; | |
| 16 | + method: string; | |
| 17 | + graphql_operation?: string; | |
| 18 | + likely_entity_types: Partial<Record<EntityType, number>>; // type → observed count | |
| 19 | + observed_count: number; | |
| 20 | + entity_yield_total: number; | |
| 21 | + first_seen: string; | |
| 22 | + last_seen: string; | |
| 23 | + confidence: number; | |
| 24 | + triggered_by: Record<string, number>; // action type → count (runtime API discovery, §71) | |
| 25 | + sample_url?: string; | |
| 26 | + fields?: { path: string; semantic?: { kind: string; confidence: number }; example?: string }[]; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface ActionPattern { | |
| 30 | + action_type: string; | |
| 31 | + from_page_type: PageType | "ANY"; | |
| 32 | + observed_count: number; | |
| 33 | + avg_new_entities: number; | |
| 34 | + avg_network_responses: number; | |
| 35 | + to_page_types: Record<string, number>; | |
| 36 | + usual_shape_hashes: Record<string, number>; | |
| 37 | + failures: number; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface PageTypeKnowledge { | |
| 41 | + page_type: PageType; | |
| 42 | + observed_count: number; | |
| 43 | + url_patterns: Record<string, number>; | |
| 44 | + avg_entities: number; | |
| 45 | + entity_types: Record<string, number>; | |
| 46 | + avg_confidence: number; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export interface PlatformModelFile { | |
| 50 | + platform: Platform; | |
| 51 | + version: number; | |
| 52 | + updated_at: string; | |
| 53 | + response_patterns: Record<string, ResponsePattern>; | |
| 54 | + action_patterns: Record<string, ActionPattern>; // key: `${action_type}@${page_type}` | |
| 55 | + page_types: Record<string, PageTypeKnowledge>; | |
| 56 | + media_patterns: Record<string, { hostname: string; kind: string; observed_count: number }>; | |
| 57 | + sessions_learned: string[]; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export interface StepOutcome { | |
| 61 | + step: number; | |
| 62 | + action_type: string; | |
| 63 | + action_id: string; | |
| 64 | + from_page_type: PageType; | |
| 65 | + to_page_type: PageType; | |
| 66 | + to_url: string; | |
| 67 | + page_confidence: number; | |
| 68 | + entities_total: number; | |
| 69 | + new_entities: number; | |
| 70 | + entity_types: Record<string, number>; | |
| 71 | + responses: { fingerprint: NetworkFingerprint; entity_count: number; kind: string; url: string; fields?: ResponsePattern["fields"] }[]; | |
| 72 | + failed: boolean; | |
| 73 | +} | |
| 74 | + | |
| 75 | +export class PlatformModel { | |
| 76 | + readonly file: string; | |
| 77 | + private model: PlatformModelFile; | |
| 78 | + private dirty = false; | |
| 79 | + | |
| 80 | + constructor(readonly platform: Platform, modelDir: string) { | |
| 81 | + const dir = path.join(modelDir, platform); | |
| 82 | + fs.mkdirSync(dir, { recursive: true }); | |
| 83 | + this.file = path.join(dir, "platform_model.json"); | |
| 84 | + this.model = fs.existsSync(this.file) | |
| 85 | + ? (JSON.parse(fs.readFileSync(this.file, "utf8")) as PlatformModelFile) | |
| 86 | + : { platform, version: 1, updated_at: nowIso(), response_patterns: {}, action_patterns: {}, page_types: {}, media_patterns: {}, sessions_learned: [] }; | |
| 87 | + } | |
| 88 | + | |
| 89 | + get data(): PlatformModelFile { | |
| 90 | + return this.model; | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** Learn from one executed step (action → observation, §30). */ | |
| 94 | + learnStep(o: StepOutcome, sessionId: string): { newPatterns: string[] } { | |
| 95 | + const m = this.model; | |
| 96 | + if (!m.sessions_learned.includes(sessionId)) m.sessions_learned.push(sessionId); | |
| 97 | + const newPatterns: string[] = []; | |
| 98 | + const now = nowIso(); | |
| 99 | + | |
| 100 | + // Action patterns | |
| 101 | + for (const key of [`${o.action_type}@${o.from_page_type}`, `${o.action_type}@ANY`]) { | |
| 102 | + const ap: ActionPattern = m.action_patterns[key] ?? { action_type: o.action_type, from_page_type: key.endsWith("@ANY") ? "ANY" : o.from_page_type, observed_count: 0, avg_new_entities: 0, avg_network_responses: 0, to_page_types: {}, usual_shape_hashes: {}, failures: 0 }; | |
| 103 | + const n = ap.observed_count; | |
| 104 | + ap.avg_new_entities = (ap.avg_new_entities * n + o.new_entities) / (n + 1); | |
| 105 | + ap.avg_network_responses = (ap.avg_network_responses * n + o.responses.length) / (n + 1); | |
| 106 | + ap.observed_count = n + 1; | |
| 107 | + ap.to_page_types[o.to_page_type] = (ap.to_page_types[o.to_page_type] ?? 0) + 1; | |
| 108 | + if (o.failed) ap.failures++; | |
| 109 | + for (const r of o.responses) if (r.entity_count > 0) ap.usual_shape_hashes[r.fingerprint.response_shape_hash] = (ap.usual_shape_hashes[r.fingerprint.response_shape_hash] ?? 0) + 1; | |
| 110 | + m.action_patterns[key] = ap; | |
| 111 | + } | |
| 112 | + | |
| 113 | + // Response patterns (runtime API discovery) | |
| 114 | + for (const r of o.responses) { | |
| 115 | + const h = r.fingerprint.response_shape_hash; | |
| 116 | + if (!h) continue; | |
| 117 | + if (r.kind === "media_manifest" || r.kind === "media_segment" || r.kind === "image") { | |
| 118 | + const mk = `${r.fingerprint.hostname}:${r.kind}`; | |
| 119 | + const mp = m.media_patterns[mk] ?? { hostname: r.fingerprint.hostname, kind: r.kind, observed_count: 0 }; | |
| 120 | + mp.observed_count++; | |
| 121 | + m.media_patterns[mk] = mp; | |
| 122 | + continue; | |
| 123 | + } | |
| 124 | + let rp = m.response_patterns[h]; | |
| 125 | + if (!rp) { | |
| 126 | + rp = { shape_hash: h, hostname: r.fingerprint.hostname, path_pattern: r.fingerprint.path_pattern, method: r.fingerprint.method, graphql_operation: r.fingerprint.graphql_operation, likely_entity_types: {}, observed_count: 0, entity_yield_total: 0, first_seen: now, last_seen: now, confidence: 0, triggered_by: {}, sample_url: r.url, fields: r.fields }; | |
| 127 | + m.response_patterns[h] = rp; | |
| 128 | + if (r.entity_count > 0 || r.fingerprint.observed_entity_types.length) newPatterns.push(h); | |
| 129 | + } | |
| 130 | + rp.observed_count++; | |
| 131 | + rp.last_seen = now; | |
| 132 | + rp.entity_yield_total += r.entity_count; | |
| 133 | + rp.triggered_by[o.action_type] = (rp.triggered_by[o.action_type] ?? 0) + 1; | |
| 134 | + for (const t of r.fingerprint.observed_entity_types) rp.likely_entity_types[t] = (rp.likely_entity_types[t] ?? 0) + 1; | |
| 135 | + if (!rp.fields && r.fields) rp.fields = r.fields; | |
| 136 | + // confidence grows with repeated observation and consistent entity yield | |
| 137 | + const consistency = rp.entity_yield_total > 0 ? Math.min(1, rp.entity_yield_total / rp.observed_count / 5) : 0.1; | |
| 138 | + rp.confidence = clamp01(1 - Math.exp(-rp.observed_count / 4)) * (0.5 + 0.5 * consistency); | |
| 139 | + } | |
| 140 | + | |
| 141 | + // Page types | |
| 142 | + const pk: PageTypeKnowledge = m.page_types[o.to_page_type] ?? { page_type: o.to_page_type, observed_count: 0, url_patterns: {}, avg_entities: 0, entity_types: {}, avg_confidence: 0 }; | |
| 143 | + const n = pk.observed_count; | |
| 144 | + pk.avg_entities = (pk.avg_entities * n + o.entities_total) / (n + 1); | |
| 145 | + pk.avg_confidence = (pk.avg_confidence * n + o.page_confidence) / (n + 1); | |
| 146 | + pk.observed_count = n + 1; | |
| 147 | + try { | |
| 148 | + const u = new URL(o.to_url); | |
| 149 | + const pat = u.pathname.split("/").map((s) => (/^[A-Za-z0-9_-]{8,}$/.test(s) || /^\d+$/.test(s) ? "*" : s)).join("/") || "/"; | |
| 150 | + pk.url_patterns[pat] = (pk.url_patterns[pat] ?? 0) + 1; | |
| 151 | + } catch { | |
| 152 | + /* ignore */ | |
| 153 | + } | |
| 154 | + for (const [t, c] of Object.entries(o.entity_types)) pk.entity_types[t] = (pk.entity_types[t] ?? 0) + c; | |
| 155 | + m.page_types[o.to_page_type] = pk; | |
| 156 | + | |
| 157 | + m.updated_at = now; | |
| 158 | + this.dirty = true; | |
| 159 | + return { newPatterns }; | |
| 160 | + } | |
| 161 | + | |
| 162 | + /** Learned average entity yield per action type — feeds the information-gain engine. */ | |
| 163 | + learnedYield(): Record<string, number> { | |
| 164 | + const out: Record<string, number> = {}; | |
| 165 | + for (const ap of Object.values(this.model.action_patterns)) if (ap.from_page_type === "ANY" && ap.observed_count >= 2) out[ap.action_type] = ap.avg_new_entities; | |
| 166 | + return out; | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * Degradation check (§32): on a page type we know well, observing far fewer entities than usual | |
| 171 | + * (and than the adapter expects) signals a broken connector. | |
| 172 | + */ | |
| 173 | + isDegraded(pageType: PageType, observed: number, adapterExpected: number): boolean { | |
| 174 | + const pk = this.model.page_types[pageType]; | |
| 175 | + const expected = Math.max(adapterExpected, pk && pk.observed_count >= 5 ? pk.avg_entities * 0.3 : 0); | |
| 176 | + return expected >= 3 && observed === 0; | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** Overall connector confidence (§74 summary). */ | |
| 180 | + summary() { | |
| 181 | + const rps = Object.values(this.model.response_patterns).filter((r) => Object.keys(r.likely_entity_types).length); | |
| 182 | + const entityTypes = new Set<string>(); | |
| 183 | + for (const r of rps) for (const t of Object.keys(r.likely_entity_types)) entityTypes.add(t); | |
| 184 | + for (const p of Object.values(this.model.page_types)) for (const t of Object.keys(p.entity_types)) entityTypes.add(t); | |
| 185 | + const conf = rps.length ? rps.reduce((s, r) => s + r.confidence, 0) / rps.length : 0; | |
| 186 | + const pageConf = Object.values(this.model.page_types).reduce((s, p) => s + p.avg_confidence, 0) / Math.max(1, Object.keys(this.model.page_types).length); | |
| 187 | + return { | |
| 188 | + platform: this.platform, | |
| 189 | + page_types: Object.keys(this.model.page_types).length, | |
| 190 | + entity_types: entityTypes.size, | |
| 191 | + navigation_actions: new Set(Object.values(this.model.action_patterns).map((a) => a.action_type)).size, | |
| 192 | + network_schemas: rps.length, | |
| 193 | + media_patterns: Object.keys(this.model.media_patterns).length, | |
| 194 | + confidence: Math.round(100 * clamp01(0.6 * conf + 0.4 * pageConf)), | |
| 195 | + sessions: this.model.sessions_learned.length, | |
| 196 | + }; | |
| 197 | + } | |
| 198 | + | |
| 199 | + save(): void { | |
| 200 | + if (!this.dirty) return; | |
| 201 | + fs.writeFileSync(this.file, JSON.stringify(this.model, null, 2)); | |
| 202 | + // Also split into the documented files (§29) for humans / other workers. | |
| 203 | + const dir = path.dirname(this.file); | |
| 204 | + fs.writeFileSync(path.join(dir, "response_patterns.json"), JSON.stringify(Object.values(this.model.response_patterns).sort((a, b) => b.confidence - a.confidence), null, 2)); | |
| 205 | + fs.writeFileSync(path.join(dir, "action_patterns.json"), JSON.stringify(Object.values(this.model.action_patterns), null, 2)); | |
| 206 | + fs.writeFileSync(path.join(dir, "page_types.json"), JSON.stringify(Object.values(this.model.page_types), null, 2)); | |
| 207 | + fs.writeFileSync(path.join(dir, "media_patterns.json"), JSON.stringify(Object.values(this.model.media_patterns), null, 2)); | |
| 208 | + this.dirty = false; | |
| 209 | + log.debug("platform model saved", { file: this.file }); | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** Emit CONNECTOR_PATTERN_LEARNED for freshly learned response shapes. */ | |
| 213 | + announce(bus: EventBus, sessionId: string, step: number, hashes: string[]): void { | |
| 214 | + for (const h of hashes) { | |
| 215 | + const rp = this.model.response_patterns[h]; | |
| 216 | + if (!rp) continue; | |
| 217 | + bus.emit({ | |
| 218 | + event_type: "CONNECTOR_PATTERN_LEARNED", | |
| 219 | + platform: this.platform, | |
| 220 | + session_id: sessionId, | |
| 221 | + step, | |
| 222 | + payload: { pattern: "network_response", shape_hash: h, hostname: rp.hostname, path_pattern: rp.path_pattern, graphql_operation: rp.graphql_operation, likely_entity_types: rp.likely_entity_types, observed_count: rp.observed_count, confidence: rp.confidence }, | |
| 223 | + }); | |
| 224 | + } | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +/** Compile a learned model into a connector manifest (§31/§74) — first version: declarative YAML-ish JSON. */ | |
| 229 | +export function compileConnector(model: PlatformModel, outDir: string): string { | |
| 230 | + const s = model.summary(); | |
| 231 | + const d = model.data; | |
| 232 | + fs.mkdirSync(outDir, { recursive: true }); | |
| 233 | + const manifest = { | |
| 234 | + platform: model.platform, | |
| 235 | + compiled_at: nowIso(), | |
| 236 | + learned_from_sessions: d.sessions_learned.length, | |
| 237 | + confidence: s.confidence, | |
| 238 | + page_types: Object.values(d.page_types).map((p) => ({ page_type: p.page_type, url_patterns: Object.entries(p.url_patterns).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k]) => k), avg_entities: Math.round(p.avg_entities * 10) / 10 })), | |
| 239 | + network_patterns: Object.values(d.response_patterns) | |
| 240 | + .filter((r) => r.confidence >= 0.2 && Object.keys(r.likely_entity_types).length) | |
| 241 | + .sort((a, b) => b.confidence - a.confidence) | |
| 242 | + .map((r) => ({ shape_hash: r.shape_hash, status: r.confidence >= 0.5 ? "stable" : "provisional", hostname: r.hostname, path_pattern: r.path_pattern, method: r.method, graphql_operation: r.graphql_operation, likely_entity_types: r.likely_entity_types, confidence: Math.round(r.confidence * 100) / 100, triggered_by: r.triggered_by, key_fields: (r.fields ?? []).filter((f) => f.semantic && f.semantic.confidence >= 0.7).slice(0, 25) })), | |
| 243 | + navigation: Object.values(d.action_patterns).filter((a) => a.from_page_type !== "ANY").map((a) => ({ action: a.action_type, from: a.from_page_type, to: a.to_page_types, avg_new_entities: Math.round(a.avg_new_entities * 10) / 10, observed: a.observed_count })), | |
| 244 | + media: Object.values(d.media_patterns), | |
| 245 | + }; | |
| 246 | + const file = path.join(outDir, "manifest.json"); | |
| 247 | + fs.writeFileSync(file, JSON.stringify(manifest, null, 2)); | |
| 248 | + return file; | |
| 249 | +} | |
| 250 | + | |
| 251 | +/** Subscribe to the bus for lightweight bookkeeping (pattern announcements are produced by the engine). */ | |
| 252 | +export function attachPlatformModel(model: PlatformModel, bus: EventBus): () => void { | |
| 253 | + return bus.on("SESSION_ENDED", (_ev: SocialEvent) => model.save()); | |
| 254 | +} | |
added
packages/shared/package.json
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/shared", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": {} | |
| 12 | +} | |
added
packages/shared/src/config.ts
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import fs from "node:fs"; | |
| 3 | + | |
| 4 | +/** Minimal .env loader (no dependency). Does not override already-set variables. */ | |
| 5 | +export function loadDotEnv(cwd = process.cwd()): void { | |
| 6 | + const file = path.join(cwd, ".env"); | |
| 7 | + if (!fs.existsSync(file)) return; | |
| 8 | + for (const line of fs.readFileSync(file, "utf8").split("\n")) { | |
| 9 | + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); | |
| 10 | + if (!m) continue; | |
| 11 | + const key = m[1]!; | |
| 12 | + let val = m[2]!; | |
| 13 | + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1); | |
| 14 | + if (process.env[key] === undefined) process.env[key] = val; | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface AppConfig { | |
| 19 | + dataDir: string; | |
| 20 | + profilesDir: string; | |
| 21 | + sessionsDir: string; | |
| 22 | + platformModelDir: string; | |
| 23 | + mediaDir: string; | |
| 24 | + databaseUrl?: string; | |
| 25 | + headless: boolean; | |
| 26 | + browserChannel: string; | |
| 27 | + llmProvider: "none" | "anthropic" | "local"; | |
| 28 | + llmModel: string; | |
| 29 | + localLlmUrl?: string; | |
| 30 | + localLlmKey?: string; | |
| 31 | + localLlmModel?: string; | |
| 32 | + dashboardPort: number; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function loadConfig(): AppConfig { | |
| 36 | + loadDotEnv(); | |
| 37 | + const dataDir = path.resolve(process.env.SRC_DATA_DIR ?? "./data"); | |
| 38 | + const cfg: AppConfig = { | |
| 39 | + dataDir, | |
| 40 | + profilesDir: path.join(dataDir, "browser_profiles"), | |
| 41 | + sessionsDir: path.join(dataDir, "sessions"), | |
| 42 | + platformModelDir: path.join(dataDir, "platform_model"), | |
| 43 | + mediaDir: path.join(dataDir, "media"), | |
| 44 | + databaseUrl: process.env.SRC_DATABASE_URL || undefined, | |
| 45 | + headless: /^(1|true|yes)$/i.test(process.env.SRC_HEADLESS ?? "false"), | |
| 46 | + browserChannel: process.env.SRC_BROWSER_CHANNEL ?? "chromium", | |
| 47 | + llmProvider: (process.env.SRC_LLM_PROVIDER as AppConfig["llmProvider"]) ?? "none", | |
| 48 | + llmModel: process.env.SRC_LLM_MODEL ?? "claude-opus-5", | |
| 49 | + localLlmUrl: process.env.SRC_LOCAL_LLM_URL, | |
| 50 | + localLlmKey: process.env.SRC_LOCAL_LLM_KEY, | |
| 51 | + localLlmModel: process.env.SRC_LOCAL_LLM_MODEL, | |
| 52 | + dashboardPort: Number(process.env.SRC_DASHBOARD_PORT ?? 8340), | |
| 53 | + }; | |
| 54 | + for (const d of [cfg.profilesDir, cfg.sessionsDir, cfg.platformModelDir, cfg.mediaDir]) fs.mkdirSync(d, { recursive: true }); | |
| 55 | + return cfg; | |
| 56 | +} | |
added
packages/shared/src/index.ts
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +export * from "./types.ts"; | |
| 2 | +export * from "./util.ts"; | |
| 3 | +export * from "./logger.ts"; | |
| 4 | +export * from "./config.ts"; | |
added
packages/shared/src/logger.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +/** Structured logger: one JSON line per record on stderr, human line on stdout when SRC_LOG_PRETTY=1. */ | |
| 2 | +export type LogLevel = "debug" | "info" | "warn" | "error"; | |
| 3 | + | |
| 4 | +const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }; | |
| 5 | +const minLevel = LEVELS[(process.env.SRC_LOG_LEVEL as LogLevel) ?? "info"] ?? 20; | |
| 6 | +const pretty = process.env.SRC_LOG_PRETTY !== "0"; | |
| 7 | + | |
| 8 | +export interface Logger { | |
| 9 | + debug(msg: string, data?: Record<string, unknown>): void; | |
| 10 | + info(msg: string, data?: Record<string, unknown>): void; | |
| 11 | + warn(msg: string, data?: Record<string, unknown>): void; | |
| 12 | + error(msg: string, data?: Record<string, unknown>): void; | |
| 13 | + child(bindings: Record<string, unknown>): Logger; | |
| 14 | +} | |
| 15 | + | |
| 16 | +function emit(level: LogLevel, scope: string, bindings: Record<string, unknown>, msg: string, data?: Record<string, unknown>) { | |
| 17 | + if (LEVELS[level] < minLevel) return; | |
| 18 | + const rec = { t: new Date().toISOString(), level, scope, msg, ...bindings, ...(data ?? {}) }; | |
| 19 | + if (pretty) { | |
| 20 | + const extra = { ...bindings, ...(data ?? {}) }; | |
| 21 | + const tail = Object.keys(extra).length ? " " + JSON.stringify(extra) : ""; | |
| 22 | + const line = `${rec.t.slice(11, 19)} ${level.toUpperCase().padEnd(5)} [${scope}] ${msg}${tail}`; | |
| 23 | + (level === "error" || level === "warn" ? process.stderr : process.stdout).write(line + "\n"); | |
| 24 | + } else { | |
| 25 | + process.stderr.write(JSON.stringify(rec) + "\n"); | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function createLogger(scope: string, bindings: Record<string, unknown> = {}): Logger { | |
| 30 | + return { | |
| 31 | + debug: (m, d) => emit("debug", scope, bindings, m, d), | |
| 32 | + info: (m, d) => emit("info", scope, bindings, m, d), | |
| 33 | + warn: (m, d) => emit("warn", scope, bindings, m, d), | |
| 34 | + error: (m, d) => emit("error", scope, bindings, m, d), | |
| 35 | + child: (b) => createLogger(scope, { ...bindings, ...b }), | |
| 36 | + }; | |
| 37 | +} | |
added
packages/shared/src/types.ts
+311 −0
@@ -0,0 +1,311 @@ | ||
| 1 | +/** | |
| 2 | + * Core vocabulary of the Social Runtime Crawler. | |
| 3 | + * Everything here is platform-independent. Platform quirks live in adapters. | |
| 4 | + */ | |
| 5 | + | |
| 6 | +export const PLATFORMS = [ | |
| 7 | + "youtube", | |
| 8 | + "reddit", | |
| 9 | + "facebook", | |
| 10 | + "instagram", | |
| 11 | + "tiktok", | |
| 12 | + "x", | |
| 13 | + "linkedin", | |
| 14 | + "threads", | |
| 15 | +] as const; | |
| 16 | +export type Platform = (typeof PLATFORMS)[number]; | |
| 17 | + | |
| 18 | +export function isPlatform(v: string): v is Platform { | |
| 19 | + return (PLATFORMS as readonly string[]).includes(v); | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** Observation surfaces (§9). */ | |
| 23 | +export type Surface = | |
| 24 | + | "network" | |
| 25 | + | "dom" | |
| 26 | + | "runtime_state" | |
| 27 | + | "accessibility" | |
| 28 | + | "visual" | |
| 29 | + | "media" | |
| 30 | + | "navigation"; | |
| 31 | + | |
| 32 | +export interface Provenance { | |
| 33 | + surface: Surface; | |
| 34 | + confidence: number; // 0..1 | |
| 35 | + observation_id?: string; | |
| 36 | + detail?: string; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Page classification (§58). */ | |
| 40 | +export type PageType = | |
| 41 | + | "HOME_FEED" | |
| 42 | + | "SEARCH_RESULTS" | |
| 43 | + | "PROFILE" | |
| 44 | + | "PUBLIC_PAGE" | |
| 45 | + | "POST_DETAIL" | |
| 46 | + | "VIDEO_DETAIL" | |
| 47 | + | "CHANNEL" | |
| 48 | + | "GROUP" | |
| 49 | + | "COMMENT_VIEW" | |
| 50 | + | "LOGIN" | |
| 51 | + | "UNKNOWN"; | |
| 52 | + | |
| 53 | +export interface PageClassification { | |
| 54 | + page_type: PageType; | |
| 55 | + confidence: number; | |
| 56 | + signals: string[]; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Entity ontology (§22). */ | |
| 60 | +export type EntityType = | |
| 61 | + | "person" | |
| 62 | + | "organization" | |
| 63 | + | "page" | |
| 64 | + | "profile" | |
| 65 | + | "channel" | |
| 66 | + | "account" | |
| 67 | + | "post" | |
| 68 | + | "comment" | |
| 69 | + | "video" | |
| 70 | + | "image" | |
| 71 | + | "topic" | |
| 72 | + | "hashtag" | |
| 73 | + | "url" | |
| 74 | + | "event" | |
| 75 | + | "location" | |
| 76 | + | "product" | |
| 77 | + | "organization_role" | |
| 78 | + | "community"; | |
| 79 | + | |
| 80 | +export type RelationType = | |
| 81 | + | "AUTHORED" | |
| 82 | + | "MENTIONED" | |
| 83 | + | "REPLIED_TO" | |
| 84 | + | "POSTED_BY" | |
| 85 | + | "BELONGS_TO" | |
| 86 | + | "LINKS_TO" | |
| 87 | + | "FEATURES" | |
| 88 | + | "HAS_PROFILE" | |
| 89 | + | "REPRESENTS" | |
| 90 | + | "DISCOVERED_FROM"; | |
| 91 | + | |
| 92 | +/** A value with provenance (§9, §38). */ | |
| 93 | +export interface Evidenced<T = unknown> { | |
| 94 | + value: T; | |
| 95 | + provenance: Provenance[]; | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * An entity as observed on a page during one step. | |
| 100 | + * `ref` is a short, stable-within-a-step handle exposed to the planner (E1, E2…). | |
| 101 | + */ | |
| 102 | +export interface ObservedEntity { | |
| 103 | + ref: string; | |
| 104 | + type: EntityType; | |
| 105 | + platform: Platform; | |
| 106 | + platform_id?: string; // e.g. YouTube videoId, Reddit t3_xxx | |
| 107 | + url?: string; | |
| 108 | + name?: string; // display name / title | |
| 109 | + text?: string; // caption / body excerpt | |
| 110 | + author?: string; | |
| 111 | + author_url?: string; | |
| 112 | + metrics?: Partial<Record<"views" | "likes" | "comments" | "shares" | "score" | "subscribers" | "followers", number>>; | |
| 113 | + media?: { has_video: boolean; has_image: boolean; duration_s?: number; thumbnail_url?: string }; | |
| 114 | + published_text?: string; | |
| 115 | + context?: string; // e.g. "feed item #12", "search result", "comment author" | |
| 116 | + fields: Record<string, Evidenced>; | |
| 117 | + provenance: Provenance[]; | |
| 118 | + /** Deterministic fingerprint used for dedup across steps (platform + id or url). */ | |
| 119 | + fingerprint: string; | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** Semantic action vocabulary (§25). */ | |
| 123 | +export type ActionType = | |
| 124 | + | "OPEN_ENTITY" | |
| 125 | + | "OPEN_POST" | |
| 126 | + | "OPEN_PROFILE" | |
| 127 | + | "OPEN_PAGE" | |
| 128 | + | "OPEN_CHANNEL" | |
| 129 | + | "OPEN_VIDEO" | |
| 130 | + | "OPEN_COMMENTS" | |
| 131 | + | "SCROLL_DOWN" | |
| 132 | + | "SCROLL_UP" | |
| 133 | + | "SEARCH" | |
| 134 | + | "FILTER" | |
| 135 | + | "PLAY_VIDEO" | |
| 136 | + | "PAUSE_VIDEO" | |
| 137 | + | "EXPAND" | |
| 138 | + | "COLLAPSE" | |
| 139 | + | "BACK" | |
| 140 | + | "FORWARD" | |
| 141 | + | "RETURN_TO_FEED" | |
| 142 | + | "WAIT_FOR_CONTENT" | |
| 143 | + | "END_SESSION"; | |
| 144 | + | |
| 145 | +export interface SemanticAction { | |
| 146 | + id: string; // A1, A2… | |
| 147 | + type: ActionType; | |
| 148 | + target_ref?: string; // entity ref for OPEN_* actions | |
| 149 | + target_url?: string; | |
| 150 | + query?: string; // for SEARCH | |
| 151 | + label: string; // human/LLM readable | |
| 152 | + cost: number; // relative exploration cost (1 = scroll) | |
| 153 | +} | |
| 154 | + | |
| 155 | +export interface ActionScore { | |
| 156 | + action_id: string; | |
| 157 | + novelty: number; | |
| 158 | + relevance: number; | |
| 159 | + expected_entity_yield: number; | |
| 160 | + confidence: number; | |
| 161 | + source_quality: number; | |
| 162 | + cost: number; | |
| 163 | + penalties: string[]; | |
| 164 | + information_gain: number; | |
| 165 | +} | |
| 166 | + | |
| 167 | +export interface AgentDecision { | |
| 168 | + step: number; | |
| 169 | + goal: string; | |
| 170 | + chosen_action: SemanticAction; | |
| 171 | + expected_information_gain: number; | |
| 172 | + novelty: number; | |
| 173 | + relevance: number; | |
| 174 | + reason: string; // concise explanation only — never chain-of-thought | |
| 175 | + planner: "heuristic" | "llm" | "fallback"; | |
| 176 | + scores: ActionScore[]; | |
| 177 | +} | |
| 178 | + | |
| 179 | +export type AgentMode = "observe" | "research" | "profile" | "topic" | "learn"; | |
| 180 | + | |
| 181 | +export interface CrawlBudget { | |
| 182 | + max_minutes: number; | |
| 183 | + max_actions: number; | |
| 184 | + max_profiles: number; | |
| 185 | + max_posts: number; | |
| 186 | + max_videos: number; | |
| 187 | + max_depth: number; | |
| 188 | + max_llm_tokens: number; | |
| 189 | + max_storage_mb: number; | |
| 190 | +} | |
| 191 | + | |
| 192 | +export const DEFAULT_BUDGET: CrawlBudget = { | |
| 193 | + max_minutes: 10, | |
| 194 | + max_actions: 60, | |
| 195 | + max_profiles: 25, | |
| 196 | + max_posts: 200, | |
| 197 | + max_videos: 100, | |
| 198 | + max_depth: 5, | |
| 199 | + max_llm_tokens: 200_000, | |
| 200 | + max_storage_mb: 500, | |
| 201 | +}; | |
| 202 | + | |
| 203 | +export type MediaLevel = 0 | 1 | 2 | 3 | 4; | |
| 204 | + | |
| 205 | +export interface CrawlJob { | |
| 206 | + job_id: string; | |
| 207 | + platform: Platform; | |
| 208 | + account_alias: string; | |
| 209 | + mode: AgentMode; | |
| 210 | + goal: string; | |
| 211 | + seed_url?: string; | |
| 212 | + query?: string; | |
| 213 | + budget: CrawlBudget; | |
| 214 | + media_level: MediaLevel; | |
| 215 | + stay_on_platform: boolean; | |
| 216 | + read_only: true; // §46 — prototype is strictly read-only | |
| 217 | +} | |
| 218 | + | |
| 219 | +/** Compact page state handed to the planner (§14). */ | |
| 220 | +export interface PageState { | |
| 221 | + url: string; | |
| 222 | + title: string; | |
| 223 | + platform: Platform; | |
| 224 | + classification: PageClassification; | |
| 225 | + entities: ObservedEntity[]; | |
| 226 | + actions: SemanticAction[]; | |
| 227 | + media: ObservedMedia[]; | |
| 228 | + summary_text: string; // the textual semantic DOM representation | |
| 229 | + fingerprint: string; // page fingerprint (url + visible entity ids) for loop detection | |
| 230 | + captured_at: string; | |
| 231 | +} | |
| 232 | + | |
| 233 | +export interface ObservedMedia { | |
| 234 | + media_type: "video" | "image" | "audio"; | |
| 235 | + platform: Platform; | |
| 236 | + platform_media_id?: string; | |
| 237 | + url?: string; | |
| 238 | + page_url?: string; | |
| 239 | + title?: string; | |
| 240 | + author?: string; | |
| 241 | + duration_s?: number; | |
| 242 | + width?: number; | |
| 243 | + height?: number; | |
| 244 | + thumbnail_url?: string; | |
| 245 | + delivery?: { kind: "progressive" | "hls" | "dash" | "unknown"; manifest_url?: string; hostnames: string[] }; | |
| 246 | + fingerprint: string; | |
| 247 | + provenance: Provenance[]; | |
| 248 | +} | |
| 249 | + | |
| 250 | +/** Network fingerprint (§10). */ | |
| 251 | +export interface NetworkFingerprint { | |
| 252 | + hostname: string; | |
| 253 | + path_pattern: string; // path with numeric / id-like segments replaced by * | |
| 254 | + method: string; | |
| 255 | + content_type: string; | |
| 256 | + response_shape_hash: string; | |
| 257 | + is_graphql: boolean; | |
| 258 | + graphql_operation?: string; | |
| 259 | + observed_entity_types: EntityType[]; | |
| 260 | +} | |
| 261 | + | |
| 262 | +export interface SchemaField { | |
| 263 | + path: string; // dotted path, arrays as [] | |
| 264 | + types: string[]; | |
| 265 | + semantic: SemanticFieldGuess[]; | |
| 266 | + examples: string[]; | |
| 267 | + frequency: number; // 0..1 across profiled objects | |
| 268 | +} | |
| 269 | + | |
| 270 | +export type SemanticFieldKind = | |
| 271 | + | "identifier" | |
| 272 | + | "username" | |
| 273 | + | "display_name" | |
| 274 | + | "title" | |
| 275 | + | "text" | |
| 276 | + | "url" | |
| 277 | + | "media_url" | |
| 278 | + | "thumbnail_url" | |
| 279 | + | "timestamp" | |
| 280 | + | "count" | |
| 281 | + | "duration" | |
| 282 | + | "cursor" | |
| 283 | + | "boolean" | |
| 284 | + | "unknown"; | |
| 285 | + | |
| 286 | +export interface SemanticFieldGuess { | |
| 287 | + kind: SemanticFieldKind; | |
| 288 | + confidence: number; | |
| 289 | +} | |
| 290 | + | |
| 291 | +export interface SchemaProfile { | |
| 292 | + shape_hash: string; | |
| 293 | + root_type: "object" | "array" | "scalar"; | |
| 294 | + repeated_object_paths: string[]; // arrays of similar objects → candidate entity lists | |
| 295 | + fields: SchemaField[]; | |
| 296 | + candidate_entity_types: { type: EntityType; confidence: number; path: string }[]; | |
| 297 | + object_count: number; | |
| 298 | +} | |
| 299 | + | |
| 300 | +export interface SessionInfo { | |
| 301 | + session_id: string; | |
| 302 | + platform: Platform; | |
| 303 | + account_alias: string; | |
| 304 | + profile_path: string; | |
| 305 | + started_at: string; | |
| 306 | + current_url?: string; | |
| 307 | + current_entity?: string; | |
| 308 | + navigation_depth: number; | |
| 309 | + last_action?: string; | |
| 310 | + health: "starting" | "healthy" | "degraded" | "auth_required" | "crashed" | "stopped"; | |
| 311 | +} | |
added
packages/shared/src/util.test.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { parseCount, parseDuration, tokenize, canonicalUrl } from "./util.ts"; | |
| 3 | + | |
| 4 | +describe("parseCount", () => { | |
| 5 | + it("handles fr/en thousands, decimals, suffixes and unicode spaces", () => { | |
| 6 | + expect(parseCount("3 624 visionnements")).toBe(3624); | |
| 7 | + expect(parseCount("3 624 vues")).toBe(3624); | |
| 8 | + expect(parseCount("12,345 views")).toBe(12345); | |
| 9 | + expect(parseCount("1,2 M de vues")).toBe(1_200_000); | |
| 10 | + expect(parseCount("48 k vues")).toBe(48_000); | |
| 11 | + expect(parseCount("483")).toBe(483); | |
| 12 | + expect(parseCount("17 k")).toBe(17_000); | |
| 13 | + expect(parseCount("no number")).toBeUndefined(); | |
| 14 | + }); | |
| 15 | +}); | |
| 16 | +describe("parseDuration", () => { | |
| 17 | + it("parses mm:ss and h:mm:ss", () => { | |
| 18 | + expect(parseDuration("12:34")).toBe(754); | |
| 19 | + expect(parseDuration("1:02:03")).toBe(3723); | |
| 20 | + }); | |
| 21 | +}); | |
| 22 | +describe("tokenize", () => { | |
| 23 | + it("keeps 2-letter domain tokens and drops stopwords", () => { | |
| 24 | + const t = tokenize("Discover public Quebec creators discussing AI"); | |
| 25 | + expect(t.has("ai")).toBe(true); | |
| 26 | + expect(t.has("quebec")).toBe(true); | |
| 27 | + expect(t.has("public")).toBe(false); | |
| 28 | + }); | |
| 29 | +}); | |
| 30 | +describe("canonicalUrl", () => { | |
| 31 | + it("strips tracking params and www", () => { | |
| 32 | + expect(canonicalUrl("https://www.youtube.com/watch?v=abc&si=xyz&t=10")).toBe("https://youtube.com/watch?v=abc"); | |
| 33 | + }); | |
| 34 | +}); | |
added
packages/shared/src/util.ts
+125 −0
@@ -0,0 +1,125 @@ | ||
| 1 | +import { createHash, randomUUID } from "node:crypto"; | |
| 2 | + | |
| 3 | +export function nowIso(): string { | |
| 4 | + return new Date().toISOString(); | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function newId(prefix: string): string { | |
| 8 | + return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 20)}`; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export function sha1(input: string): string { | |
| 12 | + return createHash("sha1").update(input).digest("hex"); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function shortHash(input: string, len = 12): string { | |
| 16 | + return sha1(input).slice(0, len); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function clamp01(n: number): number { | |
| 20 | + if (Number.isNaN(n)) return 0; | |
| 21 | + return Math.max(0, Math.min(1, n)); | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function truncate(s: string | undefined, max: number): string { | |
| 25 | + if (!s) return ""; | |
| 26 | + const t = s.replace(/\s+/g, " ").trim(); | |
| 27 | + return t.length > max ? t.slice(0, max - 1) + "…" : t; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function sleep(ms: number): Promise<void> { | |
| 31 | + return new Promise((r) => setTimeout(r, ms)); | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Parse "1.2M", "483", "12 k", "3,4 M de vues" → number. Returns undefined when nothing numeric. */ | |
| 35 | +export function parseCount(text: string | undefined | null): number | undefined { | |
| 36 | + if (!text) return undefined; | |
| 37 | + const m = text.replace(/ /g, " ").replace(/\s+/g, " ").match(/(\d{1,3}(?:[ ,.]\d{3})+|\d+(?:[.,]\d+)?)\s*([kKmMbBG])?(?![a-z])/i); | |
| 38 | + // matches "3 624", "12,345", "1,2 M", "48 k", "483" (all unicode spaces normalised first) | |
| 39 | + if (!m) return undefined; | |
| 40 | + let raw = m[1]!; | |
| 41 | + const suffix = (m[2] ?? "").toLowerCase(); | |
| 42 | + if (/^\d{1,3}(?:[ ,.]\d{3})+$/.test(raw) && !suffix) raw = raw.replace(/[ ,.]/g, ""); // thousands groups | |
| 43 | + else raw = raw.replace(/ /g, "").replace(",", "."); // decimal comma (fr) | |
| 44 | + let n = Number(raw); | |
| 45 | + if (Number.isNaN(n)) return undefined; | |
| 46 | + if (suffix === "k") n *= 1e3; | |
| 47 | + else if (suffix === "m") n *= 1e6; | |
| 48 | + else if (suffix === "b" || suffix === "g") n *= 1e9; | |
| 49 | + return Math.round(n); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Parse "12:34" / "1:02:03" → seconds. */ | |
| 53 | +export function parseDuration(text: string | undefined | null): number | undefined { | |
| 54 | + if (!text) return undefined; | |
| 55 | + const m = text.trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); | |
| 56 | + if (!m) return undefined; | |
| 57 | + const h = m[1] ? Number(m[1]) : 0; | |
| 58 | + return h * 3600 + Number(m[2]) * 60 + Number(m[3]); | |
| 59 | +} | |
| 60 | + | |
| 61 | +const STOPWORDS = new Set(["the", "and", "for", "with", "that", "this", "from", "les", "des", "une", "sur", "pour", "dans", "est", "qui", "que", "de", "la", "le", "du", "en", "un", "et", "au", "of", "to", "in", "on", "is", "it", "or", "by", "an", "as", "at", "be", "we", "vs", "ce", "se", "sa", "son", "ses", "ne", "pas", "plus", "how", "what", "why", "who", "public", "discover", "discussing", "about"]); | |
| 62 | + | |
| 63 | +/** Tokenize for cheap lexical similarity (novelty fallback when no embedding model is present). */ | |
| 64 | +export function tokenize(text: string): Set<string> { | |
| 65 | + return new Set( | |
| 66 | + text | |
| 67 | + .toLowerCase() | |
| 68 | + .normalize("NFD") | |
| 69 | + .replace(/[̀-ͯ]/g, "") | |
| 70 | + .split(/[^a-z0-9#@]+/) | |
| 71 | + // keep 2-letter tokens: "ai", "ia", "qc" carry meaning in this domain | |
| 72 | + .filter((t) => t.length >= 2 && !STOPWORDS.has(t)), | |
| 73 | + ); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function jaccard(a: Set<string>, b: Set<string>): number { | |
| 77 | + if (a.size === 0 || b.size === 0) return 0; | |
| 78 | + let inter = 0; | |
| 79 | + for (const t of a) if (b.has(t)) inter++; | |
| 80 | + return inter / (a.size + b.size - inter); | |
| 81 | +} | |
| 82 | + | |
| 83 | +export function canonicalUrl(url: string): string { | |
| 84 | + try { | |
| 85 | + const u = new URL(url); | |
| 86 | + u.hash = ""; | |
| 87 | + // strip common tracking params | |
| 88 | + for (const k of [...u.searchParams.keys()]) { | |
| 89 | + if (/^(utm_|fbclid|gclid|igshid|si|feature|pp|ref_src|ref_url|t)$/i.test(k)) u.searchParams.delete(k); | |
| 90 | + } | |
| 91 | + u.hostname = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, ""); | |
| 92 | + let s = u.toString(); | |
| 93 | + if (s.endsWith("/")) s = s.slice(0, -1); | |
| 94 | + return s; | |
| 95 | + } catch { | |
| 96 | + return url; | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +export function safeJsonParse(text: string): unknown | undefined { | |
| 101 | + try { | |
| 102 | + return JSON.parse(text); | |
| 103 | + } catch { | |
| 104 | + return undefined; | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** Walk any JSON value depth-first. Visitor receives (value, path). Return false to stop descending. */ | |
| 109 | +export function walkJson( | |
| 110 | + value: unknown, | |
| 111 | + visitor: (v: unknown, path: string[]) => boolean | void, | |
| 112 | + path: string[] = [], | |
| 113 | + depth = 0, | |
| 114 | +): void { | |
| 115 | + if (depth > 40) return; | |
| 116 | + const cont = visitor(value, path); | |
| 117 | + if (cont === false) return; | |
| 118 | + if (Array.isArray(value)) { | |
| 119 | + value.forEach((v, i) => walkJson(v, visitor, [...path, `[${i}]`], depth + 1)); | |
| 120 | + } else if (value && typeof value === "object") { | |
| 121 | + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| 122 | + walkJson(v, visitor, [...path, k], depth + 1); | |
| 123 | + } | |
| 124 | + } | |
| 125 | +} | |
added
packages/storage/package.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@src/storage", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./src/index.ts", | |
| 7 | + "types": "./src/index.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": "./src/index.ts" | |
| 10 | + }, | |
| 11 | + "dependencies": { | |
| 12 | + "@src/shared": "workspace:*", | |
| 13 | + "@src/events": "workspace:*", | |
| 14 | + "pg": "^8.16.0" | |
| 15 | + }, | |
| 16 | + "devDependencies": { | |
| 17 | + "@types/pg": "^8.15.0" | |
| 18 | + } | |
| 19 | +} | |
added
packages/storage/src/index.ts
+0 −0
Binary file not shown.
added
packages/storage/src/migrate.ts
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +import { loadConfig } from "@src/shared"; | |
| 2 | +import { PostgresStore } from "./index.ts"; | |
| 3 | + | |
| 4 | +const cfg = loadConfig(); | |
| 5 | +if (!cfg.databaseUrl) { | |
| 6 | + console.error("SRC_DATABASE_URL is not set"); | |
| 7 | + process.exit(1); | |
| 8 | +} | |
| 9 | +const store = await PostgresStore.connect(cfg.databaseUrl); | |
| 10 | +await store.migrate(); | |
| 11 | +await store.close(); | |
| 12 | +console.log("migrated", cfg.databaseUrl.replace(/\/\/.*@/, "//***@")); | |
added
packages/storage/src/schema.sql
+180 −0
@@ -0,0 +1,180 @@ | ||
| 1 | +-- Social Runtime Crawler — prototype schema (§36). Raw observations are append-only; canonical tables are derived. | |
| 2 | +CREATE TABLE IF NOT EXISTS sessions ( | |
| 3 | + session_id text PRIMARY KEY, | |
| 4 | + platform text NOT NULL, | |
| 5 | + account_alias text NOT NULL, | |
| 6 | + mode text, | |
| 7 | + goal text, | |
| 8 | + started_at timestamptz NOT NULL DEFAULT now(), | |
| 9 | + ended_at timestamptz, | |
| 10 | + health text, | |
| 11 | + stats jsonb | |
| 12 | +); | |
| 13 | + | |
| 14 | +-- Every event, raw (§37). Never updated. | |
| 15 | +CREATE TABLE IF NOT EXISTS observations ( | |
| 16 | + event_id text PRIMARY KEY, | |
| 17 | + session_id text NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, | |
| 18 | + platform text NOT NULL, | |
| 19 | + event_type text NOT NULL, | |
| 20 | + step integer, | |
| 21 | + ts timestamptz NOT NULL, | |
| 22 | + payload jsonb NOT NULL, | |
| 23 | + provenance jsonb, | |
| 24 | + discovered_via jsonb | |
| 25 | +); | |
| 26 | +CREATE INDEX IF NOT EXISTS observations_session_idx ON observations(session_id, step); | |
| 27 | +CREATE INDEX IF NOT EXISTS observations_type_idx ON observations(event_type); | |
| 28 | + | |
| 29 | +CREATE TABLE IF NOT EXISTS entities ( | |
| 30 | + fingerprint text PRIMARY KEY, | |
| 31 | + platform text NOT NULL, | |
| 32 | + entity_type text NOT NULL, | |
| 33 | + platform_id text, | |
| 34 | + url text, | |
| 35 | + name text, | |
| 36 | + text_excerpt text, | |
| 37 | + author text, | |
| 38 | + metrics jsonb, | |
| 39 | + media jsonb, | |
| 40 | + fields jsonb NOT NULL DEFAULT '{}'::jsonb, -- field → {value, provenance[]} (§38 evidence model) | |
| 41 | + confidence real, | |
| 42 | + first_seen timestamptz NOT NULL DEFAULT now(), | |
| 43 | + last_seen timestamptz NOT NULL DEFAULT now(), | |
| 44 | + seen_count integer NOT NULL DEFAULT 1, | |
| 45 | + first_session text, | |
| 46 | + canonical_id text | |
| 47 | +); | |
| 48 | +CREATE INDEX IF NOT EXISTS entities_platform_type_idx ON entities(platform, entity_type); | |
| 49 | + | |
| 50 | +CREATE TABLE IF NOT EXISTS entity_observations ( | |
| 51 | + id bigserial PRIMARY KEY, | |
| 52 | + fingerprint text NOT NULL REFERENCES entities(fingerprint) ON DELETE CASCADE, | |
| 53 | + session_id text NOT NULL, | |
| 54 | + step integer, | |
| 55 | + ts timestamptz NOT NULL, | |
| 56 | + surfaces text[] NOT NULL, | |
| 57 | + snapshot jsonb NOT NULL | |
| 58 | +); | |
| 59 | +CREATE INDEX IF NOT EXISTS entity_observations_fp_idx ON entity_observations(fingerprint); | |
| 60 | + | |
| 61 | +CREATE TABLE IF NOT EXISTS media ( | |
| 62 | + fingerprint text PRIMARY KEY, | |
| 63 | + platform text NOT NULL, | |
| 64 | + media_type text NOT NULL, | |
| 65 | + platform_media_id text, | |
| 66 | + page_url text, | |
| 67 | + url text, | |
| 68 | + title text, | |
| 69 | + author text, | |
| 70 | + duration_s real, | |
| 71 | + width integer, | |
| 72 | + height integer, | |
| 73 | + thumbnail_url text, | |
| 74 | + delivery jsonb, | |
| 75 | + frames text[], | |
| 76 | + provenance jsonb, | |
| 77 | + first_seen timestamptz NOT NULL DEFAULT now(), | |
| 78 | + last_seen timestamptz NOT NULL DEFAULT now() | |
| 79 | +); | |
| 80 | + | |
| 81 | +CREATE TABLE IF NOT EXISTS relationships ( | |
| 82 | + id bigserial PRIMARY KEY, | |
| 83 | + session_id text NOT NULL, | |
| 84 | + from_fp text NOT NULL, | |
| 85 | + to_fp text NOT NULL, | |
| 86 | + rel_type text NOT NULL, | |
| 87 | + step integer, | |
| 88 | + UNIQUE (from_fp, to_fp, rel_type) | |
| 89 | +); | |
| 90 | + | |
| 91 | +CREATE TABLE IF NOT EXISTS actions ( | |
| 92 | + id bigserial PRIMARY KEY, | |
| 93 | + session_id text NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, | |
| 94 | + step integer NOT NULL, | |
| 95 | + action_id text, | |
| 96 | + action_type text NOT NULL, | |
| 97 | + label text, | |
| 98 | + target_url text, | |
| 99 | + planner text, | |
| 100 | + expected_gain real, | |
| 101 | + novelty real, | |
| 102 | + relevance real, | |
| 103 | + reason text, | |
| 104 | + scores jsonb, | |
| 105 | + before_state jsonb, | |
| 106 | + after_state jsonb, | |
| 107 | + success boolean, | |
| 108 | + error text, | |
| 109 | + duration_ms integer, | |
| 110 | + ts timestamptz NOT NULL DEFAULT now() | |
| 111 | +); | |
| 112 | +CREATE INDEX IF NOT EXISTS actions_session_idx ON actions(session_id, step); | |
| 113 | + | |
| 114 | +CREATE TABLE IF NOT EXISTS network_responses ( | |
| 115 | + request_id text PRIMARY KEY, | |
| 116 | + session_id text NOT NULL, | |
| 117 | + step integer, | |
| 118 | + url text NOT NULL, | |
| 119 | + method text, | |
| 120 | + status integer, | |
| 121 | + kind text, | |
| 122 | + content_type text, | |
| 123 | + body_size integer, | |
| 124 | + shape_hash text, | |
| 125 | + hostname text, | |
| 126 | + path_pattern text, | |
| 127 | + graphql_operation text, | |
| 128 | + entity_count integer, | |
| 129 | + entity_types text[], | |
| 130 | + confidence real, | |
| 131 | + ts timestamptz NOT NULL | |
| 132 | +); | |
| 133 | +CREATE INDEX IF NOT EXISTS network_responses_shape_idx ON network_responses(shape_hash); | |
| 134 | + | |
| 135 | +CREATE TABLE IF NOT EXISTS schema_patterns ( | |
| 136 | + platform text NOT NULL, | |
| 137 | + shape_hash text NOT NULL, | |
| 138 | + fingerprint jsonb NOT NULL, | |
| 139 | + schema jsonb NOT NULL, | |
| 140 | + sample_url text, | |
| 141 | + observed_count integer NOT NULL DEFAULT 1, | |
| 142 | + first_seen timestamptz NOT NULL DEFAULT now(), | |
| 143 | + last_seen timestamptz NOT NULL DEFAULT now(), | |
| 144 | + PRIMARY KEY (platform, shape_hash) | |
| 145 | +); | |
| 146 | + | |
| 147 | +CREATE TABLE IF NOT EXISTS feed_items ( | |
| 148 | + id bigserial PRIMARY KEY, | |
| 149 | + session_id text NOT NULL, | |
| 150 | + step integer, | |
| 151 | + page_url text, | |
| 152 | + page_type text, | |
| 153 | + feed_position integer, | |
| 154 | + fingerprint text NOT NULL, | |
| 155 | + entity_type text, | |
| 156 | + visible boolean, | |
| 157 | + ts timestamptz NOT NULL | |
| 158 | +); | |
| 159 | + | |
| 160 | +CREATE TABLE IF NOT EXISTS connector_versions ( | |
| 161 | + id bigserial PRIMARY KEY, | |
| 162 | + platform text NOT NULL, | |
| 163 | + compiled_at timestamptz NOT NULL DEFAULT now(), | |
| 164 | + confidence integer, | |
| 165 | + summary jsonb, | |
| 166 | + manifest_path text | |
| 167 | +); | |
| 168 | + | |
| 169 | +CREATE TABLE IF NOT EXISTS crawl_jobs ( | |
| 170 | + job_id text PRIMARY KEY, | |
| 171 | + session_id text, | |
| 172 | + platform text NOT NULL, | |
| 173 | + mode text, | |
| 174 | + goal text, | |
| 175 | + budget jsonb, | |
| 176 | + status text, | |
| 177 | + created_at timestamptz NOT NULL DEFAULT now(), | |
| 178 | + finished_at timestamptz, | |
| 179 | + result jsonb | |
| 180 | +); | |
added
pnpm-lock.yaml
+1381 −0
@@ -0,0 +1,1381 @@ | ||
| 1 | +lockfileVersion: '9.0' | |
| 2 | + | |
| 3 | +settings: | |
| 4 | + autoInstallPeers: true | |
| 5 | + excludeLinksFromLockfile: false | |
| 6 | + | |
| 7 | +importers: | |
| 8 | + | |
| 9 | + .: | |
| 10 | + devDependencies: | |
| 11 | + '@types/node': | |
| 12 | + specifier: ^24.0.0 | |
| 13 | + version: 24.13.4 | |
| 14 | + tsx: | |
| 15 | + specifier: ^4.20.0 | |
| 16 | + version: 4.23.13 | |
| 17 | + typescript: | |
| 18 | + specifier: ^5.9.3 | |
| 19 | + version: 5.9.3 | |
| 20 | + vitest: | |
| 21 | + specifier: ^3.2.0 | |
| 22 | + version: 3.2.7(@types/node@24.13.4)(tsx@4.23.13) | |
| 23 | + | |
| 24 | + apps/api: | |
| 25 | + dependencies: | |
| 26 | + '@src/shared': | |
| 27 | + specifier: workspace:* | |
| 28 | + version: link:../../packages/shared | |
| 29 | + '@src/storage': | |
| 30 | + specifier: workspace:* | |
| 31 | + version: link:../../packages/storage | |
| 32 | + | |
| 33 | + apps/worker: | |
| 34 | + dependencies: | |
| 35 | + '@src/agent': | |
| 36 | + specifier: workspace:* | |
| 37 | + version: link:../../packages/agent | |
| 38 | + '@src/browser': | |
| 39 | + specifier: workspace:* | |
| 40 | + version: link:../../packages/browser | |
| 41 | + '@src/connectors': | |
| 42 | + specifier: workspace:* | |
| 43 | + version: link:../../packages/connectors | |
| 44 | + '@src/entities': | |
| 45 | + specifier: workspace:* | |
| 46 | + version: link:../../packages/entities | |
| 47 | + '@src/events': | |
| 48 | + specifier: workspace:* | |
| 49 | + version: link:../../packages/events | |
| 50 | + '@src/media': | |
| 51 | + specifier: workspace:* | |
| 52 | + version: link:../../packages/media | |
| 53 | + '@src/observers': | |
| 54 | + specifier: workspace:* | |
| 55 | + version: link:../../packages/observers | |
| 56 | + '@src/platform-model': | |
| 57 | + specifier: workspace:* | |
| 58 | + version: link:../../packages/platform-model | |
| 59 | + '@src/shared': | |
| 60 | + specifier: workspace:* | |
| 61 | + version: link:../../packages/shared | |
| 62 | + '@src/storage': | |
| 63 | + specifier: workspace:* | |
| 64 | + version: link:../../packages/storage | |
| 65 | + playwright: | |
| 66 | + specifier: ^1.63.0 | |
| 67 | + version: 1.63.0 | |
| 68 | + | |
| 69 | + packages/agent: | |
| 70 | + dependencies: | |
| 71 | + '@anthropic-ai/sdk': | |
| 72 | + specifier: ^0.125.0 | |
| 73 | + version: 0.125.0 | |
| 74 | + '@src/events': | |
| 75 | + specifier: workspace:* | |
| 76 | + version: link:../events | |
| 77 | + '@src/shared': | |
| 78 | + specifier: workspace:* | |
| 79 | + version: link:../shared | |
| 80 | + | |
| 81 | + packages/browser: | |
| 82 | + dependencies: | |
| 83 | + '@src/events': | |
| 84 | + specifier: workspace:* | |
| 85 | + version: link:../events | |
| 86 | + '@src/shared': | |
| 87 | + specifier: workspace:* | |
| 88 | + version: link:../shared | |
| 89 | + playwright: | |
| 90 | + specifier: ^1.63.0 | |
| 91 | + version: 1.63.0 | |
| 92 | + | |
| 93 | + packages/connectors: | |
| 94 | + dependencies: | |
| 95 | + '@src/observers': | |
| 96 | + specifier: workspace:* | |
| 97 | + version: link:../observers | |
| 98 | + '@src/shared': | |
| 99 | + specifier: workspace:* | |
| 100 | + version: link:../shared | |
| 101 | + playwright: | |
| 102 | + specifier: ^1.63.0 | |
| 103 | + version: 1.63.0 | |
| 104 | + | |
| 105 | + packages/entities: | |
| 106 | + dependencies: | |
| 107 | + '@src/events': | |
| 108 | + specifier: workspace:* | |
| 109 | + version: link:../events | |
| 110 | + '@src/shared': | |
| 111 | + specifier: workspace:* | |
| 112 | + version: link:../shared | |
| 113 | + | |
| 114 | + packages/events: | |
| 115 | + dependencies: | |
| 116 | + '@src/shared': | |
| 117 | + specifier: workspace:* | |
| 118 | + version: link:../shared | |
| 119 | + | |
| 120 | + packages/media: | |
| 121 | + dependencies: | |
| 122 | + '@src/events': | |
| 123 | + specifier: workspace:* | |
| 124 | + version: link:../events | |
| 125 | + '@src/shared': | |
| 126 | + specifier: workspace:* | |
| 127 | + version: link:../shared | |
| 128 | + playwright: | |
| 129 | + specifier: ^1.63.0 | |
| 130 | + version: 1.63.0 | |
| 131 | + | |
| 132 | + packages/observers: | |
| 133 | + dependencies: | |
| 134 | + '@src/events': | |
| 135 | + specifier: workspace:* | |
| 136 | + version: link:../events | |
| 137 | + '@src/shared': | |
| 138 | + specifier: workspace:* | |
| 139 | + version: link:../shared | |
| 140 | + playwright: | |
| 141 | + specifier: ^1.63.0 | |
| 142 | + version: 1.63.0 | |
| 143 | + | |
| 144 | + packages/platform-model: | |
| 145 | + dependencies: | |
| 146 | + '@src/events': | |
| 147 | + specifier: workspace:* | |
| 148 | + version: link:../events | |
| 149 | + '@src/shared': | |
| 150 | + specifier: workspace:* | |
| 151 | + version: link:../shared | |
| 152 | + | |
| 153 | + packages/shared: {} | |
| 154 | + | |
| 155 | + packages/storage: | |
| 156 | + dependencies: | |
| 157 | + '@src/events': | |
| 158 | + specifier: workspace:* | |
| 159 | + version: link:../events | |
| 160 | + '@src/shared': | |
| 161 | + specifier: workspace:* | |
| 162 | + version: link:../shared | |
| 163 | + pg: | |
| 164 | + specifier: ^8.16.0 | |
| 165 | + version: 8.23.0 | |
| 166 | + devDependencies: | |
| 167 | + '@types/pg': | |
| 168 | + specifier: ^8.15.0 | |
| 169 | + version: 8.23.1 | |
| 170 | + | |
| 171 | +packages: | |
| 172 | + | |
| 173 | + '@anthropic-ai/sdk@0.125.0': | |
| 174 | + resolution: {integrity: sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg==} | |
| 175 | + hasBin: true | |
| 176 | + peerDependencies: | |
| 177 | + zod: ^3.25.0 || ^4.0.0 | |
| 178 | + peerDependenciesMeta: | |
| 179 | + zod: | |
| 180 | + optional: true | |
| 181 | + | |
| 182 | + '@babel/runtime@7.29.7': | |
| 183 | + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} | |
| 184 | + engines: {node: '>=6.9.0'} | |
| 185 | + | |
| 186 | + '@esbuild/aix-ppc64@0.28.2': | |
| 187 | + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} | |
| 188 | + engines: {node: '>=18'} | |
| 189 | + cpu: [ppc64] | |
| 190 | + os: [aix] | |
| 191 | + | |
| 192 | + '@esbuild/android-arm64@0.28.2': | |
| 193 | + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} | |
| 194 | + engines: {node: '>=18'} | |
| 195 | + cpu: [arm64] | |
| 196 | + os: [android] | |
| 197 | + | |
| 198 | + '@esbuild/android-arm@0.28.2': | |
| 199 | + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} | |
| 200 | + engines: {node: '>=18'} | |
| 201 | + cpu: [arm] | |
| 202 | + os: [android] | |
| 203 | + | |
| 204 | + '@esbuild/android-x64@0.28.2': | |
| 205 | + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} | |
| 206 | + engines: {node: '>=18'} | |
| 207 | + cpu: [x64] | |
| 208 | + os: [android] | |
| 209 | + | |
| 210 | + '@esbuild/darwin-arm64@0.28.2': | |
| 211 | + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} | |
| 212 | + engines: {node: '>=18'} | |
| 213 | + cpu: [arm64] | |
| 214 | + os: [darwin] | |
| 215 | + | |
| 216 | + '@esbuild/darwin-x64@0.28.2': | |
| 217 | + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} | |
| 218 | + engines: {node: '>=18'} | |
| 219 | + cpu: [x64] | |
| 220 | + os: [darwin] | |
| 221 | + | |
| 222 | + '@esbuild/freebsd-arm64@0.28.2': | |
| 223 | + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} | |
| 224 | + engines: {node: '>=18'} | |
| 225 | + cpu: [arm64] | |
| 226 | + os: [freebsd] | |
| 227 | + | |
| 228 | + '@esbuild/freebsd-x64@0.28.2': | |
| 229 | + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} | |
| 230 | + engines: {node: '>=18'} | |
| 231 | + cpu: [x64] | |
| 232 | + os: [freebsd] | |
| 233 | + | |
| 234 | + '@esbuild/linux-arm64@0.28.2': | |
| 235 | + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} | |
| 236 | + engines: {node: '>=18'} | |
| 237 | + cpu: [arm64] | |
| 238 | + os: [linux] | |
| 239 | + | |
| 240 | + '@esbuild/linux-arm@0.28.2': | |
| 241 | + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} | |
| 242 | + engines: {node: '>=18'} | |
| 243 | + cpu: [arm] | |
| 244 | + os: [linux] | |
| 245 | + | |
| 246 | + '@esbuild/linux-ia32@0.28.2': | |
| 247 | + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} | |
| 248 | + engines: {node: '>=18'} | |
| 249 | + cpu: [ia32] | |
| 250 | + os: [linux] | |
| 251 | + | |
| 252 | + '@esbuild/linux-loong64@0.28.2': | |
| 253 | + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} | |
| 254 | + engines: {node: '>=18'} | |
| 255 | + cpu: [loong64] | |
| 256 | + os: [linux] | |
| 257 | + | |
| 258 | + '@esbuild/linux-mips64el@0.28.2': | |
| 259 | + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} | |
| 260 | + engines: {node: '>=18'} | |
| 261 | + cpu: [mips64el] | |
| 262 | + os: [linux] | |
| 263 | + | |
| 264 | + '@esbuild/linux-ppc64@0.28.2': | |
| 265 | + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} | |
| 266 | + engines: {node: '>=18'} | |
| 267 | + cpu: [ppc64] | |
| 268 | + os: [linux] | |
| 269 | + | |
| 270 | + '@esbuild/linux-riscv64@0.28.2': | |
| 271 | + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} | |
| 272 | + engines: {node: '>=18'} | |
| 273 | + cpu: [riscv64] | |
| 274 | + os: [linux] | |
| 275 | + | |
| 276 | + '@esbuild/linux-s390x@0.28.2': | |
| 277 | + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} | |
| 278 | + engines: {node: '>=18'} | |
| 279 | + cpu: [s390x] | |
| 280 | + os: [linux] | |
| 281 | + | |
| 282 | + '@esbuild/linux-x64@0.28.2': | |
| 283 | + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} | |
| 284 | + engines: {node: '>=18'} | |
| 285 | + cpu: [x64] | |
| 286 | + os: [linux] | |
| 287 | + | |
| 288 | + '@esbuild/netbsd-arm64@0.28.2': | |
| 289 | + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} | |
| 290 | + engines: {node: '>=18'} | |
| 291 | + cpu: [arm64] | |
| 292 | + os: [netbsd] | |
| 293 | + | |
| 294 | + '@esbuild/netbsd-x64@0.28.2': | |
| 295 | + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} | |
| 296 | + engines: {node: '>=18'} | |
| 297 | + cpu: [x64] | |
| 298 | + os: [netbsd] | |
| 299 | + | |
| 300 | + '@esbuild/openbsd-arm64@0.28.2': | |
| 301 | + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} | |
| 302 | + engines: {node: '>=18'} | |
| 303 | + cpu: [arm64] | |
| 304 | + os: [openbsd] | |
| 305 | + | |
| 306 | + '@esbuild/openbsd-x64@0.28.2': | |
| 307 | + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} | |
| 308 | + engines: {node: '>=18'} | |
| 309 | + cpu: [x64] | |
| 310 | + os: [openbsd] | |
| 311 | + | |
| 312 | + '@esbuild/openharmony-arm64@0.28.2': | |
| 313 | + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} | |
| 314 | + engines: {node: '>=18'} | |
| 315 | + cpu: [arm64] | |
| 316 | + os: [openharmony] | |
| 317 | + | |
| 318 | + '@esbuild/sunos-x64@0.28.2': | |
| 319 | + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} | |
| 320 | + engines: {node: '>=18'} | |
| 321 | + cpu: [x64] | |
| 322 | + os: [sunos] | |
| 323 | + | |
| 324 | + '@esbuild/win32-arm64@0.28.2': | |
| 325 | + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} | |
| 326 | + engines: {node: '>=18'} | |
| 327 | + cpu: [arm64] | |
| 328 | + os: [win32] | |
| 329 | + | |
| 330 | + '@esbuild/win32-ia32@0.28.2': | |
| 331 | + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} | |
| 332 | + engines: {node: '>=18'} | |
| 333 | + cpu: [ia32] | |
| 334 | + os: [win32] | |
| 335 | + | |
| 336 | + '@esbuild/win32-x64@0.28.2': | |
| 337 | + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} | |
| 338 | + engines: {node: '>=18'} | |
| 339 | + cpu: [x64] | |
| 340 | + os: [win32] | |
| 341 | + | |
| 342 | + '@jridgewell/sourcemap-codec@1.6.0': | |
| 343 | + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} | |
| 344 | + | |
| 345 | + '@napi-rs/lzma-linux-x64-gnu@1.5.1': | |
| 346 | + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} | |
| 347 | + engines: {node: ^22.20 || ^24.12 || >=25} | |
| 348 | + cpu: [x64] | |
| 349 | + os: [linux] | |
| 350 | + libc: [glibc] | |
| 351 | + | |
| 352 | + '@rollup/rollup-android-arm-eabi@4.63.1': | |
| 353 | + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} | |
| 354 | + cpu: [arm] | |
| 355 | + os: [android] | |
| 356 | + | |
| 357 | + '@rollup/rollup-android-arm64@4.63.1': | |
| 358 | + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} | |
| 359 | + cpu: [arm64] | |
| 360 | + os: [android] | |
| 361 | + | |
| 362 | + '@rollup/rollup-darwin-arm64@4.63.1': | |
| 363 | + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} | |
| 364 | + cpu: [arm64] | |
| 365 | + os: [darwin] | |
| 366 | + | |
| 367 | + '@rollup/rollup-darwin-x64@4.63.1': | |
| 368 | + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} | |
| 369 | + cpu: [x64] | |
| 370 | + os: [darwin] | |
| 371 | + | |
| 372 | + '@rollup/rollup-freebsd-arm64@4.63.1': | |
| 373 | + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} | |
| 374 | + cpu: [arm64] | |
| 375 | + os: [freebsd] | |
| 376 | + | |
| 377 | + '@rollup/rollup-freebsd-x64@4.63.1': | |
| 378 | + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} | |
| 379 | + cpu: [x64] | |
| 380 | + os: [freebsd] | |
| 381 | + | |
| 382 | + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': | |
| 383 | + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} | |
| 384 | + cpu: [arm] | |
| 385 | + os: [linux] | |
| 386 | + libc: [glibc] | |
| 387 | + | |
| 388 | + '@rollup/rollup-linux-arm-musleabihf@4.63.1': | |
| 389 | + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} | |
| 390 | + cpu: [arm] | |
| 391 | + os: [linux] | |
| 392 | + libc: [musl] | |
| 393 | + | |
| 394 | + '@rollup/rollup-linux-arm64-gnu@4.63.1': | |
| 395 | + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} | |
| 396 | + cpu: [arm64] | |
| 397 | + os: [linux] | |
| 398 | + libc: [glibc] | |
| 399 | + | |
| 400 | + '@rollup/rollup-linux-arm64-musl@4.63.1': | |
| 401 | + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} | |
| 402 | + cpu: [arm64] | |
| 403 | + os: [linux] | |
| 404 | + libc: [musl] | |
| 405 | + | |
| 406 | + '@rollup/rollup-linux-loong64-gnu@4.63.1': | |
| 407 | + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} | |
| 408 | + cpu: [loong64] | |
| 409 | + os: [linux] | |
| 410 | + libc: [glibc] | |
| 411 | + | |
| 412 | + '@rollup/rollup-linux-loong64-musl@4.63.1': | |
| 413 | + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} | |
| 414 | + cpu: [loong64] | |
| 415 | + os: [linux] | |
| 416 | + libc: [musl] | |
| 417 | + | |
| 418 | + '@rollup/rollup-linux-ppc64-gnu@4.63.1': | |
| 419 | + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} | |
| 420 | + cpu: [ppc64] | |
| 421 | + os: [linux] | |
| 422 | + libc: [glibc] | |
| 423 | + | |
| 424 | + '@rollup/rollup-linux-ppc64-musl@4.63.1': | |
| 425 | + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} | |
| 426 | + cpu: [ppc64] | |
| 427 | + os: [linux] | |
| 428 | + libc: [musl] | |
| 429 | + | |
| 430 | + '@rollup/rollup-linux-riscv64-gnu@4.63.1': | |
| 431 | + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} | |
| 432 | + cpu: [riscv64] | |
| 433 | + os: [linux] | |
| 434 | + libc: [glibc] | |
| 435 | + | |
| 436 | + '@rollup/rollup-linux-riscv64-musl@4.63.1': | |
| 437 | + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} | |
| 438 | + cpu: [riscv64] | |
| 439 | + os: [linux] | |
| 440 | + libc: [musl] | |
| 441 | + | |
| 442 | + '@rollup/rollup-linux-s390x-gnu@4.63.1': | |
| 443 | + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} | |
| 444 | + cpu: [s390x] | |
| 445 | + os: [linux] | |
| 446 | + libc: [glibc] | |
| 447 | + | |
| 448 | + '@rollup/rollup-linux-x64-gnu@4.63.1': | |
| 449 | + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} | |
| 450 | + cpu: [x64] | |
| 451 | + os: [linux] | |
| 452 | + libc: [glibc] | |
| 453 | + | |
| 454 | + '@rollup/rollup-linux-x64-musl@4.63.1': | |
| 455 | + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} | |
| 456 | + cpu: [x64] | |
| 457 | + os: [linux] | |
| 458 | + libc: [musl] | |
| 459 | + | |
| 460 | + '@rollup/rollup-openbsd-x64@4.63.1': | |
| 461 | + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} | |
| 462 | + cpu: [x64] | |
| 463 | + os: [openbsd] | |
| 464 | + | |
| 465 | + '@rollup/rollup-openharmony-arm64@4.63.1': | |
| 466 | + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} | |
| 467 | + cpu: [arm64] | |
| 468 | + os: [openharmony] | |
| 469 | + | |
| 470 | + '@rollup/rollup-win32-arm64-msvc@4.63.1': | |
| 471 | + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} | |
| 472 | + cpu: [arm64] | |
| 473 | + os: [win32] | |
| 474 | + | |
| 475 | + '@rollup/rollup-win32-ia32-msvc@4.63.1': | |
| 476 | + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} | |
| 477 | + cpu: [ia32] | |
| 478 | + os: [win32] | |
| 479 | + | |
| 480 | + '@rollup/rollup-win32-x64-gnu@4.63.1': | |
| 481 | + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} | |
| 482 | + cpu: [x64] | |
| 483 | + os: [win32] | |
| 484 | + | |
| 485 | + '@rollup/rollup-win32-x64-msvc@4.63.1': | |
| 486 | + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} | |
| 487 | + cpu: [x64] | |
| 488 | + os: [win32] | |
| 489 | + | |
| 490 | + '@stablelib/base64@1.0.1': | |
| 491 | + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} | |
| 492 | + | |
| 493 | + '@types/chai@5.2.3': | |
| 494 | + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} | |
| 495 | + | |
| 496 | + '@types/deep-eql@4.0.2': | |
| 497 | + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} | |
| 498 | + | |
| 499 | + '@types/estree@1.0.9': | |
| 500 | + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} | |
| 501 | + | |
| 502 | + '@types/node@24.13.4': | |
| 503 | + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} | |
| 504 | + | |
| 505 | + '@types/pg@8.23.1': | |
| 506 | + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} | |
| 507 | + | |
| 508 | + '@vitest/expect@3.2.7': | |
| 509 | + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} | |
| 510 | + | |
| 511 | + '@vitest/mocker@3.2.7': | |
| 512 | + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} | |
| 513 | + peerDependencies: | |
| 514 | + msw: ^2.4.9 | |
| 515 | + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 | |
| 516 | + peerDependenciesMeta: | |
| 517 | + msw: | |
| 518 | + optional: true | |
| 519 | + vite: | |
| 520 | + optional: true | |
| 521 | + | |
| 522 | + '@vitest/pretty-format@3.2.7': | |
| 523 | + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} | |
| 524 | + | |
| 525 | + '@vitest/runner@3.2.7': | |
| 526 | + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} | |
| 527 | + | |
| 528 | + '@vitest/snapshot@3.2.7': | |
| 529 | + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} | |
| 530 | + | |
| 531 | + '@vitest/spy@3.2.7': | |
| 532 | + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} | |
| 533 | + | |
| 534 | + '@vitest/utils@3.2.7': | |
| 535 | + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} | |
| 536 | + | |
| 537 | + assertion-error@2.0.1: | |
| 538 | + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} | |
| 539 | + engines: {node: '>=12'} | |
| 540 | + | |
| 541 | + cac@6.7.14: | |
| 542 | + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} | |
| 543 | + engines: {node: '>=8'} | |
| 544 | + | |
| 545 | + chai@5.3.3: | |
| 546 | + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} | |
| 547 | + engines: {node: '>=18'} | |
| 548 | + | |
| 549 | + check-error@2.1.3: | |
| 550 | + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} | |
| 551 | + engines: {node: '>= 16'} | |
| 552 | + | |
| 553 | + debug@4.4.3: | |
| 554 | + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} | |
| 555 | + engines: {node: '>=6.0'} | |
| 556 | + peerDependencies: | |
| 557 | + supports-color: '*' | |
| 558 | + peerDependenciesMeta: | |
| 559 | + supports-color: | |
| 560 | + optional: true | |
| 561 | + | |
| 562 | + deep-eql@5.0.2: | |
| 563 | + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} | |
| 564 | + engines: {node: '>=6'} | |
| 565 | + | |
| 566 | + es-module-lexer@1.7.0: | |
| 567 | + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} | |
| 568 | + | |
| 569 | + esbuild@0.28.2: | |
| 570 | + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} | |
| 571 | + engines: {node: '>=18'} | |
| 572 | + hasBin: true | |
| 573 | + | |
| 574 | + estree-walker@3.0.3: | |
| 575 | + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} | |
| 576 | + | |
| 577 | + expect-type@1.4.0: | |
| 578 | + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} | |
| 579 | + engines: {node: '>=12.0.0'} | |
| 580 | + | |
| 581 | + fast-sha256@1.3.0: | |
| 582 | + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} | |
| 583 | + | |
| 584 | + fdir@6.5.0: | |
| 585 | + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} | |
| 586 | + engines: {node: '>=12.0.0'} | |
| 587 | + peerDependencies: | |
| 588 | + picomatch: ^3 || ^4 | |
| 589 | + peerDependenciesMeta: | |
| 590 | + picomatch: | |
| 591 | + optional: true | |
| 592 | + | |
| 593 | + fsevents@2.3.3: | |
| 594 | + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} | |
| 595 | + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 596 | + os: [darwin] | |
| 597 | + | |
| 598 | + js-tokens@9.0.1: | |
| 599 | + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} | |
| 600 | + | |
| 601 | + json-schema-to-ts@3.1.1: | |
| 602 | + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} | |
| 603 | + engines: {node: '>=16'} | |
| 604 | + | |
| 605 | + loupe@3.2.1: | |
| 606 | + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} | |
| 607 | + | |
| 608 | + magic-string@0.30.21: | |
| 609 | + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} | |
| 610 | + | |
| 611 | + ms@2.1.3: | |
| 612 | + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} | |
| 613 | + | |
| 614 | + nanoid@3.3.19: | |
| 615 | + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} | |
| 616 | + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | |
| 617 | + hasBin: true | |
| 618 | + | |
| 619 | + pathe@2.0.3: | |
| 620 | + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} | |
| 621 | + | |
| 622 | + pathval@2.0.1: | |
| 623 | + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} | |
| 624 | + engines: {node: '>= 14.16'} | |
| 625 | + | |
| 626 | + pg-cloudflare@1.4.0: | |
| 627 | + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} | |
| 628 | + | |
| 629 | + pg-connection-string@2.14.0: | |
| 630 | + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} | |
| 631 | + | |
| 632 | + pg-int8@1.0.1: | |
| 633 | + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} | |
| 634 | + engines: {node: '>=4.0.0'} | |
| 635 | + | |
| 636 | + pg-pool@3.14.0: | |
| 637 | + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} | |
| 638 | + peerDependencies: | |
| 639 | + pg: '>=8.0' | |
| 640 | + | |
| 641 | + pg-protocol@1.16.0: | |
| 642 | + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} | |
| 643 | + | |
| 644 | + pg-types@2.2.0: | |
| 645 | + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} | |
| 646 | + engines: {node: '>=4'} | |
| 647 | + | |
| 648 | + pg@8.23.0: | |
| 649 | + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} | |
| 650 | + engines: {node: '>= 16.0.0'} | |
| 651 | + peerDependencies: | |
| 652 | + pg-native: '>=3.0.1' | |
| 653 | + peerDependenciesMeta: | |
| 654 | + pg-native: | |
| 655 | + optional: true | |
| 656 | + | |
| 657 | + pgpass@1.0.5: | |
| 658 | + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} | |
| 659 | + | |
| 660 | + picocolors@1.1.1: | |
| 661 | + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} | |
| 662 | + | |
| 663 | + picomatch@4.0.7: | |
| 664 | + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} | |
| 665 | + engines: {node: '>=12'} | |
| 666 | + | |
| 667 | + playwright-core@1.63.0: | |
| 668 | + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} | |
| 669 | + engines: {node: '>=20'} | |
| 670 | + hasBin: true | |
| 671 | + | |
| 672 | + playwright@1.63.0: | |
| 673 | + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} | |
| 674 | + engines: {node: '>=20'} | |
| 675 | + hasBin: true | |
| 676 | + | |
| 677 | + postcss@8.5.28: | |
| 678 | + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} | |
| 679 | + engines: {node: ^10 || ^12 || >=14} | |
| 680 | + | |
| 681 | + postgres-array@2.0.0: | |
| 682 | + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} | |
| 683 | + engines: {node: '>=4'} | |
| 684 | + | |
| 685 | + postgres-bytea@1.0.1: | |
| 686 | + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} | |
| 687 | + engines: {node: '>=0.10.0'} | |
| 688 | + | |
| 689 | + postgres-date@1.0.7: | |
| 690 | + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} | |
| 691 | + engines: {node: '>=0.10.0'} | |
| 692 | + | |
| 693 | + postgres-interval@1.2.0: | |
| 694 | + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} | |
| 695 | + engines: {node: '>=0.10.0'} | |
| 696 | + | |
| 697 | + rollup@4.63.1: | |
| 698 | + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} | |
| 699 | + engines: {node: '>=18.0.0', npm: '>=8.0.0'} | |
| 700 | + hasBin: true | |
| 701 | + | |
| 702 | + siginfo@2.0.0: | |
| 703 | + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} | |
| 704 | + | |
| 705 | + source-map-js@1.2.1: | |
| 706 | + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} | |
| 707 | + engines: {node: '>=0.10.0'} | |
| 708 | + | |
| 709 | + split2@4.2.0: | |
| 710 | + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} | |
| 711 | + engines: {node: '>= 10.x'} | |
| 712 | + | |
| 713 | + stackback@0.0.2: | |
| 714 | + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} | |
| 715 | + | |
| 716 | + standardwebhooks@1.1.1: | |
| 717 | + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} | |
| 718 | + | |
| 719 | + std-env@3.10.0: | |
| 720 | + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} | |
| 721 | + | |
| 722 | + strip-literal@3.1.0: | |
| 723 | + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} | |
| 724 | + | |
| 725 | + tinybench@2.9.0: | |
| 726 | + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} | |
| 727 | + | |
| 728 | + tinyexec@0.3.2: | |
| 729 | + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} | |
| 730 | + | |
| 731 | + tinyglobby@0.2.17: | |
| 732 | + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} | |
| 733 | + engines: {node: '>=12.0.0'} | |
| 734 | + | |
| 735 | + tinypool@1.1.1: | |
| 736 | + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} | |
| 737 | + engines: {node: ^18.0.0 || >=20.0.0} | |
| 738 | + | |
| 739 | + tinyrainbow@2.0.0: | |
| 740 | + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} | |
| 741 | + engines: {node: '>=14.0.0'} | |
| 742 | + | |
| 743 | + tinyspy@4.0.6: | |
| 744 | + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} | |
| 745 | + engines: {node: '>=14.0.0'} | |
| 746 | + | |
| 747 | + ts-algebra@2.0.0: | |
| 748 | + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} | |
| 749 | + | |
| 750 | + tsx@4.23.13: | |
| 751 | + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} | |
| 752 | + engines: {node: '>=18.0.0'} | |
| 753 | + hasBin: true | |
| 754 | + | |
| 755 | + typescript@5.9.3: | |
| 756 | + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} | |
| 757 | + engines: {node: '>=14.17'} | |
| 758 | + hasBin: true | |
| 759 | + | |
| 760 | + undici-types@7.18.2: | |
| 761 | + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} | |
| 762 | + | |
| 763 | + vite-node@3.2.4: | |
| 764 | + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} | |
| 765 | + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} | |
| 766 | + hasBin: true | |
| 767 | + | |
| 768 | + vite@7.3.6: | |
| 769 | + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} | |
| 770 | + engines: {node: ^20.19.0 || >=22.12.0} | |
| 771 | + hasBin: true | |
| 772 | + peerDependencies: | |
| 773 | + '@types/node': ^20.19.0 || >=22.12.0 | |
| 774 | + jiti: '>=1.21.0' | |
| 775 | + less: ^4.0.0 | |
| 776 | + lightningcss: ^1.21.0 | |
| 777 | + sass: ^1.70.0 | |
| 778 | + sass-embedded: ^1.70.0 | |
| 779 | + stylus: '>=0.54.8' | |
| 780 | + sugarss: ^5.0.0 | |
| 781 | + terser: ^5.16.0 | |
| 782 | + tsx: ^4.8.1 | |
| 783 | + yaml: ^2.4.2 | |
| 784 | + peerDependenciesMeta: | |
| 785 | + '@types/node': | |
| 786 | + optional: true | |
| 787 | + jiti: | |
| 788 | + optional: true | |
| 789 | + less: | |
| 790 | + optional: true | |
| 791 | + lightningcss: | |
| 792 | + optional: true | |
| 793 | + sass: | |
| 794 | + optional: true | |
| 795 | + sass-embedded: | |
| 796 | + optional: true | |
| 797 | + stylus: | |
| 798 | + optional: true | |
| 799 | + sugarss: | |
| 800 | + optional: true | |
| 801 | + terser: | |
| 802 | + optional: true | |
| 803 | + tsx: | |
| 804 | + optional: true | |
| 805 | + yaml: | |
| 806 | + optional: true | |
| 807 | + | |
| 808 | + vitest@3.2.7: | |
| 809 | + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} | |
| 810 | + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} | |
| 811 | + hasBin: true | |
| 812 | + peerDependencies: | |
| 813 | + '@edge-runtime/vm': '*' | |
| 814 | + '@types/debug': ^4.1.12 | |
| 815 | + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 | |
| 816 | + '@vitest/browser': 3.2.7 | |
| 817 | + '@vitest/ui': 3.2.7 | |
| 818 | + happy-dom: '*' | |
| 819 | + jsdom: '*' | |
| 820 | + peerDependenciesMeta: | |
| 821 | + '@edge-runtime/vm': | |
| 822 | + optional: true | |
| 823 | + '@types/debug': | |
| 824 | + optional: true | |
| 825 | + '@types/node': | |
| 826 | + optional: true | |
| 827 | + '@vitest/browser': | |
| 828 | + optional: true | |
| 829 | + '@vitest/ui': | |
| 830 | + optional: true | |
| 831 | + happy-dom: | |
| 832 | + optional: true | |
| 833 | + jsdom: | |
| 834 | + optional: true | |
| 835 | + | |
| 836 | + why-is-node-running@2.3.0: | |
| 837 | + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} | |
| 838 | + engines: {node: '>=8'} | |
| 839 | + hasBin: true | |
| 840 | + | |
| 841 | + xtend@4.0.2: | |
| 842 | + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} | |
| 843 | + engines: {node: '>=0.4'} | |
| 844 | + | |
| 845 | +snapshots: | |
| 846 | + | |
| 847 | + '@anthropic-ai/sdk@0.125.0': | |
| 848 | + dependencies: | |
| 849 | + json-schema-to-ts: 3.1.1 | |
| 850 | + standardwebhooks: 1.1.1 | |
| 851 | + | |
| 852 | + '@babel/runtime@7.29.7': {} | |
| 853 | + | |
| 854 | + '@esbuild/aix-ppc64@0.28.2': | |
| 855 | + optional: true | |
| 856 | + | |
| 857 | + '@esbuild/android-arm64@0.28.2': | |
| 858 | + optional: true | |
| 859 | + | |
| 860 | + '@esbuild/android-arm@0.28.2': | |
| 861 | + optional: true | |
| 862 | + | |
| 863 | + '@esbuild/android-x64@0.28.2': | |
| 864 | + optional: true | |
| 865 | + | |
| 866 | + '@esbuild/darwin-arm64@0.28.2': | |
| 867 | + optional: true | |
| 868 | + | |
| 869 | + '@esbuild/darwin-x64@0.28.2': | |
| 870 | + optional: true | |
| 871 | + | |
| 872 | + '@esbuild/freebsd-arm64@0.28.2': | |
| 873 | + optional: true | |
| 874 | + | |
| 875 | + '@esbuild/freebsd-x64@0.28.2': | |
| 876 | + optional: true | |
| 877 | + | |
| 878 | + '@esbuild/linux-arm64@0.28.2': | |
| 879 | + optional: true | |
| 880 | + | |
| 881 | + '@esbuild/linux-arm@0.28.2': | |
| 882 | + optional: true | |
| 883 | + | |
| 884 | + '@esbuild/linux-ia32@0.28.2': | |
| 885 | + optional: true | |
| 886 | + | |
| 887 | + '@esbuild/linux-loong64@0.28.2': | |
| 888 | + optional: true | |
| 889 | + | |
| 890 | + '@esbuild/linux-mips64el@0.28.2': | |
| 891 | + optional: true | |
| 892 | + | |
| 893 | + '@esbuild/linux-ppc64@0.28.2': | |
| 894 | + optional: true | |
| 895 | + | |
| 896 | + '@esbuild/linux-riscv64@0.28.2': | |
| 897 | + optional: true | |
| 898 | + | |
| 899 | + '@esbuild/linux-s390x@0.28.2': | |
| 900 | + optional: true | |
| 901 | + | |
| 902 | + '@esbuild/linux-x64@0.28.2': | |
| 903 | + optional: true | |
| 904 | + | |
| 905 | + '@esbuild/netbsd-arm64@0.28.2': | |
| 906 | + optional: true | |
| 907 | + | |
| 908 | + '@esbuild/netbsd-x64@0.28.2': | |
| 909 | + optional: true | |
| 910 | + | |
| 911 | + '@esbuild/openbsd-arm64@0.28.2': | |
| 912 | + optional: true | |
| 913 | + | |
| 914 | + '@esbuild/openbsd-x64@0.28.2': | |
| 915 | + optional: true | |
| 916 | + | |
| 917 | + '@esbuild/openharmony-arm64@0.28.2': | |
| 918 | + optional: true | |
| 919 | + | |
| 920 | + '@esbuild/sunos-x64@0.28.2': | |
| 921 | + optional: true | |
| 922 | + | |
| 923 | + '@esbuild/win32-arm64@0.28.2': | |
| 924 | + optional: true | |
| 925 | + | |
| 926 | + '@esbuild/win32-ia32@0.28.2': | |
| 927 | + optional: true | |
| 928 | + | |
| 929 | + '@esbuild/win32-x64@0.28.2': | |
| 930 | + optional: true | |
| 931 | + | |
| 932 | + '@jridgewell/sourcemap-codec@1.6.0': {} | |
| 933 | + | |
| 934 | + '@napi-rs/lzma-linux-x64-gnu@1.5.1': | |
| 935 | + optional: true | |
| 936 | + | |
| 937 | + '@rollup/rollup-android-arm-eabi@4.63.1': | |
| 938 | + optional: true | |
| 939 | + | |
| 940 | + '@rollup/rollup-android-arm64@4.63.1': | |
| 941 | + optional: true | |
| 942 | + | |
| 943 | + '@rollup/rollup-darwin-arm64@4.63.1': | |
| 944 | + optional: true | |
| 945 | + | |
| 946 | + '@rollup/rollup-darwin-x64@4.63.1': | |
| 947 | + optional: true | |
| 948 | + | |
| 949 | + '@rollup/rollup-freebsd-arm64@4.63.1': | |
| 950 | + optional: true | |
| 951 | + | |
| 952 | + '@rollup/rollup-freebsd-x64@4.63.1': | |
| 953 | + optional: true | |
| 954 | + | |
| 955 | + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': | |
| 956 | + optional: true | |
| 957 | + | |
| 958 | + '@rollup/rollup-linux-arm-musleabihf@4.63.1': | |
| 959 | + optional: true | |
| 960 | + | |
| 961 | + '@rollup/rollup-linux-arm64-gnu@4.63.1': | |
| 962 | + optional: true | |
| 963 | + | |
| 964 | + '@rollup/rollup-linux-arm64-musl@4.63.1': | |
| 965 | + optional: true | |
| 966 | + | |
| 967 | + '@rollup/rollup-linux-loong64-gnu@4.63.1': | |
| 968 | + optional: true | |
| 969 | + | |
| 970 | + '@rollup/rollup-linux-loong64-musl@4.63.1': | |
| 971 | + optional: true | |
| 972 | + | |
| 973 | + '@rollup/rollup-linux-ppc64-gnu@4.63.1': | |
| 974 | + optional: true | |
| 975 | + | |
| 976 | + '@rollup/rollup-linux-ppc64-musl@4.63.1': | |
| 977 | + optional: true | |
| 978 | + | |
| 979 | + '@rollup/rollup-linux-riscv64-gnu@4.63.1': | |
| 980 | + optional: true | |
| 981 | + | |
| 982 | + '@rollup/rollup-linux-riscv64-musl@4.63.1': | |
| 983 | + optional: true | |
| 984 | + | |
| 985 | + '@rollup/rollup-linux-s390x-gnu@4.63.1': | |
| 986 | + optional: true | |
| 987 | + | |
| 988 | + '@rollup/rollup-linux-x64-gnu@4.63.1': | |
| 989 | + optional: true | |
| 990 | + | |
| 991 | + '@rollup/rollup-linux-x64-musl@4.63.1': | |
| 992 | + optional: true | |
| 993 | + | |
| 994 | + '@rollup/rollup-openbsd-x64@4.63.1': | |
| 995 | + optional: true | |
| 996 | + | |
| 997 | + '@rollup/rollup-openharmony-arm64@4.63.1': | |
| 998 | + optional: true | |
| 999 | + | |
| 1000 | + '@rollup/rollup-win32-arm64-msvc@4.63.1': | |
| 1001 | + optional: true | |
| 1002 | + | |
| 1003 | + '@rollup/rollup-win32-ia32-msvc@4.63.1': | |
| 1004 | + optional: true | |
| 1005 | + | |
| 1006 | + '@rollup/rollup-win32-x64-gnu@4.63.1': | |
| 1007 | + optional: true | |
| 1008 | + | |
| 1009 | + '@rollup/rollup-win32-x64-msvc@4.63.1': | |
| 1010 | + optional: true | |
| 1011 | + | |
| 1012 | + '@stablelib/base64@1.0.1': {} | |
| 1013 | + | |
| 1014 | + '@types/chai@5.2.3': | |
| 1015 | + dependencies: | |
| 1016 | + '@types/deep-eql': 4.0.2 | |
| 1017 | + assertion-error: 2.0.1 | |
| 1018 | + | |
| 1019 | + '@types/deep-eql@4.0.2': {} | |
| 1020 | + | |
| 1021 | + '@types/estree@1.0.9': {} | |
| 1022 | + | |
| 1023 | + '@types/node@24.13.4': | |
| 1024 | + dependencies: | |
| 1025 | + undici-types: 7.18.2 | |
| 1026 | + | |
| 1027 | + '@types/pg@8.23.1': | |
| 1028 | + dependencies: | |
| 1029 | + '@types/node': 24.13.4 | |
| 1030 | + pg-protocol: 1.16.0 | |
| 1031 | + pg-types: 2.2.0 | |
| 1032 | + | |
| 1033 | + '@vitest/expect@3.2.7': | |
| 1034 | + dependencies: | |
| 1035 | + '@types/chai': 5.2.3 | |
| 1036 | + '@vitest/spy': 3.2.7 | |
| 1037 | + '@vitest/utils': 3.2.7 | |
| 1038 | + chai: 5.3.3 | |
| 1039 | + tinyrainbow: 2.0.0 | |
| 1040 | + | |
| 1041 | + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13))': | |
| 1042 | + dependencies: | |
| 1043 | + '@vitest/spy': 3.2.7 | |
| 1044 | + estree-walker: 3.0.3 | |
| 1045 | + magic-string: 0.30.21 | |
| 1046 | + optionalDependencies: | |
| 1047 | + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) | |
| 1048 | + | |
| 1049 | + '@vitest/pretty-format@3.2.7': | |
| 1050 | + dependencies: | |
| 1051 | + tinyrainbow: 2.0.0 | |
| 1052 | + | |
| 1053 | + '@vitest/runner@3.2.7': | |
| 1054 | + dependencies: | |
| 1055 | + '@vitest/utils': 3.2.7 | |
| 1056 | + pathe: 2.0.3 | |
| 1057 | + strip-literal: 3.1.0 | |
| 1058 | + | |
| 1059 | + '@vitest/snapshot@3.2.7': | |
| 1060 | + dependencies: | |
| 1061 | + '@vitest/pretty-format': 3.2.7 | |
| 1062 | + magic-string: 0.30.21 | |
| 1063 | + pathe: 2.0.3 | |
| 1064 | + | |
| 1065 | + '@vitest/spy@3.2.7': | |
| 1066 | + dependencies: | |
| 1067 | + tinyspy: 4.0.6 | |
| 1068 | + | |
| 1069 | + '@vitest/utils@3.2.7': | |
| 1070 | + dependencies: | |
| 1071 | + '@vitest/pretty-format': 3.2.7 | |
| 1072 | + loupe: 3.2.1 | |
| 1073 | + tinyrainbow: 2.0.0 | |
| 1074 | + | |
| 1075 | + assertion-error@2.0.1: {} | |
| 1076 | + | |
| 1077 | + cac@6.7.14: {} | |
| 1078 | + | |
| 1079 | + chai@5.3.3: | |
| 1080 | + dependencies: | |
| 1081 | + assertion-error: 2.0.1 | |
| 1082 | + check-error: 2.1.3 | |
| 1083 | + deep-eql: 5.0.2 | |
| 1084 | + loupe: 3.2.1 | |
| 1085 | + pathval: 2.0.1 | |
| 1086 | + | |
| 1087 | + check-error@2.1.3: {} | |
| 1088 | + | |
| 1089 | + debug@4.4.3: | |
| 1090 | + dependencies: | |
| 1091 | + ms: 2.1.3 | |
| 1092 | + | |
| 1093 | + deep-eql@5.0.2: {} | |
| 1094 | + | |
| 1095 | + es-module-lexer@1.7.0: {} | |
| 1096 | + | |
| 1097 | + esbuild@0.28.2: | |
| 1098 | + optionalDependencies: | |
| 1099 | + '@esbuild/aix-ppc64': 0.28.2 | |
| 1100 | + '@esbuild/android-arm': 0.28.2 | |
| 1101 | + '@esbuild/android-arm64': 0.28.2 | |
| 1102 | + '@esbuild/android-x64': 0.28.2 | |
| 1103 | + '@esbuild/darwin-arm64': 0.28.2 | |
| 1104 | + '@esbuild/darwin-x64': 0.28.2 | |
| 1105 | + '@esbuild/freebsd-arm64': 0.28.2 | |
| 1106 | + '@esbuild/freebsd-x64': 0.28.2 | |
| 1107 | + '@esbuild/linux-arm': 0.28.2 | |
| 1108 | + '@esbuild/linux-arm64': 0.28.2 | |
| 1109 | + '@esbuild/linux-ia32': 0.28.2 | |
| 1110 | + '@esbuild/linux-loong64': 0.28.2 | |
| 1111 | + '@esbuild/linux-mips64el': 0.28.2 | |
| 1112 | + '@esbuild/linux-ppc64': 0.28.2 | |
| 1113 | + '@esbuild/linux-riscv64': 0.28.2 | |
| 1114 | + '@esbuild/linux-s390x': 0.28.2 | |
| 1115 | + '@esbuild/linux-x64': 0.28.2 | |
| 1116 | + '@esbuild/netbsd-arm64': 0.28.2 | |
| 1117 | + '@esbuild/netbsd-x64': 0.28.2 | |
| 1118 | + '@esbuild/openbsd-arm64': 0.28.2 | |
| 1119 | + '@esbuild/openbsd-x64': 0.28.2 | |
| 1120 | + '@esbuild/openharmony-arm64': 0.28.2 | |
| 1121 | + '@esbuild/sunos-x64': 0.28.2 | |
| 1122 | + '@esbuild/win32-arm64': 0.28.2 | |
| 1123 | + '@esbuild/win32-ia32': 0.28.2 | |
| 1124 | + '@esbuild/win32-x64': 0.28.2 | |
| 1125 | + | |
| 1126 | + estree-walker@3.0.3: | |
| 1127 | + dependencies: | |
| 1128 | + '@types/estree': 1.0.9 | |
| 1129 | + | |
| 1130 | + expect-type@1.4.0: {} | |
| 1131 | + | |
| 1132 | + fast-sha256@1.3.0: {} | |
| 1133 | + | |
| 1134 | + fdir@6.5.0(picomatch@4.0.7): | |
| 1135 | + optionalDependencies: | |
| 1136 | + picomatch: 4.0.7 | |
| 1137 | + | |
| 1138 | + fsevents@2.3.3: | |
| 1139 | + optional: true | |
| 1140 | + | |
| 1141 | + js-tokens@9.0.1: {} | |
| 1142 | + | |
| 1143 | + json-schema-to-ts@3.1.1: | |
| 1144 | + dependencies: | |
| 1145 | + '@babel/runtime': 7.29.7 | |
| 1146 | + ts-algebra: 2.0.0 | |
| 1147 | + | |
| 1148 | + loupe@3.2.1: {} | |
| 1149 | + | |
| 1150 | + magic-string@0.30.21: | |
| 1151 | + dependencies: | |
| 1152 | + '@jridgewell/sourcemap-codec': 1.6.0 | |
| 1153 | + | |
| 1154 | + ms@2.1.3: {} | |
| 1155 | + | |
| 1156 | + nanoid@3.3.19: {} | |
| 1157 | + | |
| 1158 | + pathe@2.0.3: {} | |
| 1159 | + | |
| 1160 | + pathval@2.0.1: {} | |
| 1161 | + | |
| 1162 | + pg-cloudflare@1.4.0: | |
| 1163 | + optional: true | |
| 1164 | + | |
| 1165 | + pg-connection-string@2.14.0: {} | |
| 1166 | + | |
| 1167 | + pg-int8@1.0.1: {} | |
| 1168 | + | |
| 1169 | + pg-pool@3.14.0(pg@8.23.0): | |
| 1170 | + dependencies: | |
| 1171 | + pg: 8.23.0 | |
| 1172 | + | |
| 1173 | + pg-protocol@1.16.0: {} | |
| 1174 | + | |
| 1175 | + pg-types@2.2.0: | |
| 1176 | + dependencies: | |
| 1177 | + pg-int8: 1.0.1 | |
| 1178 | + postgres-array: 2.0.0 | |
| 1179 | + postgres-bytea: 1.0.1 | |
| 1180 | + postgres-date: 1.0.7 | |
| 1181 | + postgres-interval: 1.2.0 | |
| 1182 | + | |
| 1183 | + pg@8.23.0: | |
| 1184 | + dependencies: | |
| 1185 | + pg-connection-string: 2.14.0 | |
| 1186 | + pg-pool: 3.14.0(pg@8.23.0) | |
| 1187 | + pg-protocol: 1.16.0 | |
| 1188 | + pg-types: 2.2.0 | |
| 1189 | + pgpass: 1.0.5 | |
| 1190 | + optionalDependencies: | |
| 1191 | + pg-cloudflare: 1.4.0 | |
| 1192 | + | |
| 1193 | + pgpass@1.0.5: | |
| 1194 | + dependencies: | |
| 1195 | + split2: 4.2.0 | |
| 1196 | + | |
| 1197 | + picocolors@1.1.1: {} | |
| 1198 | + | |
| 1199 | + picomatch@4.0.7: {} | |
| 1200 | + | |
| 1201 | + playwright-core@1.63.0: {} | |
| 1202 | + | |
| 1203 | + playwright@1.63.0: | |
| 1204 | + dependencies: | |
| 1205 | + playwright-core: 1.63.0 | |
| 1206 | + | |
| 1207 | + postcss@8.5.28: | |
| 1208 | + dependencies: | |
| 1209 | + nanoid: 3.3.19 | |
| 1210 | + picocolors: 1.1.1 | |
| 1211 | + source-map-js: 1.2.1 | |
| 1212 | + | |
| 1213 | + postgres-array@2.0.0: {} | |
| 1214 | + | |
| 1215 | + postgres-bytea@1.0.1: {} | |
| 1216 | + | |
| 1217 | + postgres-date@1.0.7: {} | |
| 1218 | + | |
| 1219 | + postgres-interval@1.2.0: | |
| 1220 | + dependencies: | |
| 1221 | + xtend: 4.0.2 | |
| 1222 | + | |
| 1223 | + rollup@4.63.1: | |
| 1224 | + dependencies: | |
| 1225 | + '@types/estree': 1.0.9 | |
| 1226 | + optionalDependencies: | |
| 1227 | + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 | |
| 1228 | + '@rollup/rollup-android-arm-eabi': 4.63.1 | |
| 1229 | + '@rollup/rollup-android-arm64': 4.63.1 | |
| 1230 | + '@rollup/rollup-darwin-arm64': 4.63.1 | |
| 1231 | + '@rollup/rollup-darwin-x64': 4.63.1 | |
| 1232 | + '@rollup/rollup-freebsd-arm64': 4.63.1 | |
| 1233 | + '@rollup/rollup-freebsd-x64': 4.63.1 | |
| 1234 | + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 | |
| 1235 | + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 | |
| 1236 | + '@rollup/rollup-linux-arm64-gnu': 4.63.1 | |
| 1237 | + '@rollup/rollup-linux-arm64-musl': 4.63.1 | |
| 1238 | + '@rollup/rollup-linux-loong64-gnu': 4.63.1 | |
| 1239 | + '@rollup/rollup-linux-loong64-musl': 4.63.1 | |
| 1240 | + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 | |
| 1241 | + '@rollup/rollup-linux-ppc64-musl': 4.63.1 | |
| 1242 | + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 | |
| 1243 | + '@rollup/rollup-linux-riscv64-musl': 4.63.1 | |
| 1244 | + '@rollup/rollup-linux-s390x-gnu': 4.63.1 | |
| 1245 | + '@rollup/rollup-linux-x64-gnu': 4.63.1 | |
| 1246 | + '@rollup/rollup-linux-x64-musl': 4.63.1 | |
| 1247 | + '@rollup/rollup-openbsd-x64': 4.63.1 | |
| 1248 | + '@rollup/rollup-openharmony-arm64': 4.63.1 | |
| 1249 | + '@rollup/rollup-win32-arm64-msvc': 4.63.1 | |
| 1250 | + '@rollup/rollup-win32-ia32-msvc': 4.63.1 | |
| 1251 | + '@rollup/rollup-win32-x64-gnu': 4.63.1 | |
| 1252 | + '@rollup/rollup-win32-x64-msvc': 4.63.1 | |
| 1253 | + fsevents: 2.3.3 | |
| 1254 | + | |
| 1255 | + siginfo@2.0.0: {} | |
| 1256 | + | |
| 1257 | + source-map-js@1.2.1: {} | |
| 1258 | + | |
| 1259 | + split2@4.2.0: {} | |
| 1260 | + | |
| 1261 | + stackback@0.0.2: {} | |
| 1262 | + | |
| 1263 | + standardwebhooks@1.1.1: | |
| 1264 | + dependencies: | |
| 1265 | + '@stablelib/base64': 1.0.1 | |
| 1266 | + fast-sha256: 1.3.0 | |
| 1267 | + | |
| 1268 | + std-env@3.10.0: {} | |
| 1269 | + | |
| 1270 | + strip-literal@3.1.0: | |
| 1271 | + dependencies: | |
| 1272 | + js-tokens: 9.0.1 | |
| 1273 | + | |
| 1274 | + tinybench@2.9.0: {} | |
| 1275 | + | |
| 1276 | + tinyexec@0.3.2: {} | |
| 1277 | + | |
| 1278 | + tinyglobby@0.2.17: | |
| 1279 | + dependencies: | |
| 1280 | + fdir: 6.5.0(picomatch@4.0.7) | |
| 1281 | + picomatch: 4.0.7 | |
| 1282 | + | |
| 1283 | + tinypool@1.1.1: {} | |
| 1284 | + | |
| 1285 | + tinyrainbow@2.0.0: {} | |
| 1286 | + | |
| 1287 | + tinyspy@4.0.6: {} | |
| 1288 | + | |
| 1289 | + ts-algebra@2.0.0: {} | |
| 1290 | + | |
| 1291 | + tsx@4.23.13: | |
| 1292 | + dependencies: | |
| 1293 | + esbuild: 0.28.2 | |
| 1294 | + optionalDependencies: | |
| 1295 | + fsevents: 2.3.3 | |
| 1296 | + | |
| 1297 | + typescript@5.9.3: {} | |
| 1298 | + | |
| 1299 | + undici-types@7.18.2: {} | |
| 1300 | + | |
| 1301 | + vite-node@3.2.4(@types/node@24.13.4)(tsx@4.23.13): | |
| 1302 | + dependencies: | |
| 1303 | + cac: 6.7.14 | |
| 1304 | + debug: 4.4.3 | |
| 1305 | + es-module-lexer: 1.7.0 | |
| 1306 | + pathe: 2.0.3 | |
| 1307 | + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) | |
| 1308 | + transitivePeerDependencies: | |
| 1309 | + - '@types/node' | |
| 1310 | + - jiti | |
| 1311 | + - less | |
| 1312 | + - lightningcss | |
| 1313 | + - sass | |
| 1314 | + - sass-embedded | |
| 1315 | + - stylus | |
| 1316 | + - sugarss | |
| 1317 | + - supports-color | |
| 1318 | + - terser | |
| 1319 | + - tsx | |
| 1320 | + - yaml | |
| 1321 | + | |
| 1322 | + vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13): | |
| 1323 | + dependencies: | |
| 1324 | + esbuild: 0.28.2 | |
| 1325 | + fdir: 6.5.0(picomatch@4.0.7) | |
| 1326 | + picomatch: 4.0.7 | |
| 1327 | + postcss: 8.5.28 | |
| 1328 | + rollup: 4.63.1 | |
| 1329 | + tinyglobby: 0.2.17 | |
| 1330 | + optionalDependencies: | |
| 1331 | + '@types/node': 24.13.4 | |
| 1332 | + fsevents: 2.3.3 | |
| 1333 | + tsx: 4.23.13 | |
| 1334 | + | |
| 1335 | + vitest@3.2.7(@types/node@24.13.4)(tsx@4.23.13): | |
| 1336 | + dependencies: | |
| 1337 | + '@types/chai': 5.2.3 | |
| 1338 | + '@vitest/expect': 3.2.7 | |
| 1339 | + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.4)(tsx@4.23.13)) | |
| 1340 | + '@vitest/pretty-format': 3.2.7 | |
| 1341 | + '@vitest/runner': 3.2.7 | |
| 1342 | + '@vitest/snapshot': 3.2.7 | |
| 1343 | + '@vitest/spy': 3.2.7 | |
| 1344 | + '@vitest/utils': 3.2.7 | |
| 1345 | + chai: 5.3.3 | |
| 1346 | + debug: 4.4.3 | |
| 1347 | + expect-type: 1.4.0 | |
| 1348 | + magic-string: 0.30.21 | |
| 1349 | + pathe: 2.0.3 | |
| 1350 | + picomatch: 4.0.7 | |
| 1351 | + std-env: 3.10.0 | |
| 1352 | + tinybench: 2.9.0 | |
| 1353 | + tinyexec: 0.3.2 | |
| 1354 | + tinyglobby: 0.2.17 | |
| 1355 | + tinypool: 1.1.1 | |
| 1356 | + tinyrainbow: 2.0.0 | |
| 1357 | + vite: 7.3.6(@types/node@24.13.4)(tsx@4.23.13) | |
| 1358 | + vite-node: 3.2.4(@types/node@24.13.4)(tsx@4.23.13) | |
| 1359 | + why-is-node-running: 2.3.0 | |
| 1360 | + optionalDependencies: | |
| 1361 | + '@types/node': 24.13.4 | |
| 1362 | + transitivePeerDependencies: | |
| 1363 | + - jiti | |
| 1364 | + - less | |
| 1365 | + - lightningcss | |
| 1366 | + - msw | |
| 1367 | + - sass | |
| 1368 | + - sass-embedded | |
| 1369 | + - stylus | |
| 1370 | + - sugarss | |
| 1371 | + - supports-color | |
| 1372 | + - terser | |
| 1373 | + - tsx | |
| 1374 | + - yaml | |
| 1375 | + | |
| 1376 | + why-is-node-running@2.3.0: | |
| 1377 | + dependencies: | |
| 1378 | + siginfo: 2.0.0 | |
| 1379 | + stackback: 0.0.2 | |
| 1380 | + | |
| 1381 | + xtend@4.0.2: {} | |
added
pnpm-workspace.yaml
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +packages: | |
| 2 | + - "apps/*" | |
| 3 | + - "packages/*" | |
| 4 | +onlyBuiltDependencies: | |
| 5 | + - esbuild | |
added
tsconfig.json
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "lib": ["ES2023", "DOM", "DOM.Iterable"], | |
| 7 | + "strict": true, | |
| 8 | + "noUncheckedIndexedAccess": true, | |
| 9 | + "noImplicitOverride": true, | |
| 10 | + "esModuleInterop": true, | |
| 11 | + "skipLibCheck": true, | |
| 12 | + "forceConsistentCasingInFileNames": true, | |
| 13 | + "resolveJsonModule": true, | |
| 14 | + "isolatedModules": true, | |
| 15 | + "allowImportingTsExtensions": true, | |
| 16 | + "noEmit": true, | |
| 17 | + "types": ["node"], | |
| 18 | + "baseUrl": ".", | |
| 19 | + "paths": { | |
| 20 | + "@src/shared": ["packages/shared/src/index.ts"], | |
| 21 | + "@src/events": ["packages/events/src/index.ts"], | |
| 22 | + "@src/browser": ["packages/browser/src/index.ts"], | |
| 23 | + "@src/observers": ["packages/observers/src/index.ts"], | |
| 24 | + "@src/entities": ["packages/entities/src/index.ts"], | |
| 25 | + "@src/media": ["packages/media/src/index.ts"], | |
| 26 | + "@src/agent": ["packages/agent/src/index.ts"], | |
| 27 | + "@src/platform-model": ["packages/platform-model/src/index.ts"], | |
| 28 | + "@src/storage": ["packages/storage/src/index.ts"], | |
| 29 | + "@src/connectors": ["packages/connectors/src/index.ts"] | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts"], | |
| 33 | + "exclude": ["node_modules", "**/node_modules", "data", "connectors"] | |
| 34 | +} | |
added
vitest.config.ts
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | +import { fileURLToPath } from "node:url"; | |
| 3 | +import path from "node:path"; | |
| 4 | + | |
| 5 | +const root = path.dirname(fileURLToPath(import.meta.url)); | |
| 6 | +const pkg = (name: string) => path.join(root, "packages", name, "src", "index.ts"); | |
| 7 | + | |
| 8 | +export default defineConfig({ | |
| 9 | + test: { | |
| 10 | + include: ["packages/**/*.test.ts", "apps/**/*.test.ts"], | |
| 11 | + environment: "node", | |
| 12 | + }, | |
| 13 | + resolve: { | |
| 14 | + alias: { | |
| 15 | + "@src/shared": pkg("shared"), | |
| 16 | + "@src/events": pkg("events"), | |
| 17 | + "@src/browser": pkg("browser"), | |
| 18 | + "@src/observers": pkg("observers"), | |
| 19 | + "@src/entities": pkg("entities"), | |
| 20 | + "@src/media": pkg("media"), | |
| 21 | + "@src/agent": pkg("agent"), | |
| 22 | + "@src/platform-model": pkg("platform-model"), | |
| 23 | + "@src/storage": pkg("storage"), | |
| 24 | + "@src/connectors": pkg("connectors"), | |
| 25 | + }, | |
| 26 | + }, | |
| 27 | +}); | |
| 28 | ||