diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a4b73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.turbo/ +.DS_Store +*.log +*.tsbuildinfo diff --git a/README.en.md b/README.en.md index fafa4dd..e9f96eb 100644 --- a/README.en.md +++ b/README.en.md @@ -145,3 +145,92 @@ data-recipe/ ## License TBD + +## Run the MVP locally + +### Install dependencies + +```bash +pnpm install +``` + +### Build the browser extension + +```bash +pnpm build:extension +``` + +The extension output is: + +```text +apps/extension/dist +``` + +### Load it in Chrome + +1. Open `chrome://extensions`; +2. Enable Developer mode; +3. Click "Load unpacked"; +4. Select `apps/extension/dist`; +5. Click the "AI 有数" extension icon to open the side panel. + +### Manual verification + +1. Open `docs/test-page.html`; +2. Click "开始发现" in the side panel; +3. Click the fetch or XHR test button on the test page; +4. The side panel should show the discovered data source count, URL, method, status, query data, and response preview; +5. Expand "高级信息" to inspect the minimal Data Recipe draft JSON. + +You can also start discovery on any page you are authorized to access and then trigger a page query. This MVP only performs low-frequency local detection. It does not bypass login, captchas, risk controls, or dynamic signatures. + +### Development mode + +```bash +pnpm dev:extension +``` + +This watches and rebuilds `apps/extension/dist`. Refresh the unpacked extension in Chrome before testing updated code. + +### Export a Data Skill Package + +After one discovery flow, use the side panel to: + +1. Fill in the data skill name and purpose; +2. Confirm or rename returned fields; +3. Check "试运行结果" to confirm data can be read; +4. Check "技能包预览" to confirm required files are present; +5. Click "导出技能包". + +The current MVP downloads a `.data-skill.json` file. This is a temporary text package format containing: + +```text +packageName +files[] + SKILL.md + recipe.json + examples.md + README.md +``` + +A later version can export a real folder or zip. For now, the JSON text package validates the generate, test, and export loop. + +### Data Skill Package acceptance checks + +An exported package should at least: + +* Include `SKILL.md`, `recipe.json`, `examples.md`, and `README.md`; +* Use `SKILL.md` to explain what the data skill is useful for; +* Use `recipe.json` to hold the data source, query inputs, returned fields, and test-run information; +* Show a data preview in the side panel test-run result; +* Show no missing required files before export. + +### Validate an exported skill package + +After exporting a `.data-skill.json` file, run: + +```bash +pnpm validate:skill-package path/to/your.data-skill.json +``` + +The command checks that all four required files are present and non-empty. It exits with a non-zero status when the package is invalid, so it can be used in later automated checks. diff --git a/README.md b/README.md index 098ab72..6ba2d69 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,19 @@ Turn web page queries and API responses into reusable data recipes for AI agents * [中文说明](./README.zh-CN.md) * [English README](./README.en.md) + +## MVP quick start + +```bash +pnpm install +pnpm build:extension +``` + +Load `apps/extension/dist` as an unpacked extension in Chrome, open `docs/test-page.html`, open the DataRecipe side panel, click "开始发现", trigger the fetch or XHR test request, then confirm the generated Data Skill Package and export the `.data-skill.json` file. + + +## Validate an exported skill package + +```bash +pnpm validate:skill-package path/to/your.data-skill.json +``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 03a60ce..d54a4c2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,3 +138,92 @@ data-recipe/ ## License TBD + +## 本地运行 MVP + +### 安装依赖 + +```bash +pnpm install +``` + +### 构建浏览器插件 + +```bash +pnpm build:extension +``` + +构建产物在: + +```text +apps/extension/dist +``` + +### 在 Chrome 中加载插件 + +1. 打开 `chrome://extensions`; +2. 开启「开发者模式」; +3. 点击「加载已解压的扩展程序」; +4. 选择 `apps/extension/dist`; +5. 点击浏览器工具栏里的「AI 有数」图标打开侧边栏。 + +### 手动验证 + +1. 打开 `docs/test-page.html`; +2. 在 AI 有数侧边栏点击「开始发现」; +3. 在测试页点击「触发 fetch 查询」或「触发 XHR 查询」; +4. 侧边栏应显示发现的数据来源数量、URL、请求方式、状态、查询条件和返回预览; +5. 展开「高级信息」可以查看最小数据配方草稿 JSON。 + +也可以在任意你有权访问的网页上点击「开始发现」,然后触发页面查询。当前 MVP 只做低频本地探测,不会绕过登录、验证码、风控或动态签名。 + +### 开发模式 + +```bash +pnpm dev:extension +``` + +该命令会监听并重新构建 `apps/extension/dist`。Chrome 扩展页面中需要手动点击刷新插件后再验证最新代码。 + +### 导出 Data Skill Package + +完成一次发现后,可以在侧边栏中: + +1. 为数据技能填写名称和用途说明; +2. 确认或修改返回字段名称; +3. 查看「试运行结果」确认能读取数据; +4. 在「技能包预览」中确认已包含必需文件; +5. 点击「导出技能包」。 + +当前 MVP 会下载一个 `.data-skill.json` 文件。它是临时的文本包格式,里面包含: + +```text +packageName +files[] + SKILL.md + recipe.json + examples.md + README.md +``` + +后续会把这个导出格式升级为真实文件夹或 zip。当前阶段先用 JSON 文本包验证「可生成、可测试、可导出」的闭环。 + +### Data Skill Package 验收点 + +导出的技能包至少应满足: + +* 包含 `SKILL.md`、`recipe.json`、`examples.md`、`README.md`; +* `SKILL.md` 能说明这个数据技能适合完成什么任务; +* `recipe.json` 包含数据来源、查询条件、返回字段和测试运行所需信息; +* 侧边栏「试运行结果」可以展示数据预览; +* 导出前没有提示缺少必需文件。 + +### 校验导出的技能包 + +导出 `.data-skill.json` 后,可以用下面的命令检查它是否包含四个必需文件,并确认文件内容不为空: + +```bash +pnpm validate:skill-package path/to/your.data-skill.json +``` + +校验通过时会输出技能包名称和文件列表;缺少文件或文件为空时会返回非零退出码,便于后续接入自动化验收。 diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..b83205c --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,22 @@ +{ + "name": "@data-recipe/extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rimraf dist", + "build": "tsup src/background.ts src/content.ts src/page-hook.ts src/sidepanel.ts --format iife --target es2020 --splitting false --clean --out-dir dist && node scripts/copy-assets.mjs", + "dev": "tsup src/background.ts src/content.ts src/page-hook.ts src/sidepanel.ts --format iife --target es2020 --splitting false --watch --out-dir dist --onSuccess \"node scripts/copy-assets.mjs\"", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@data-recipe/detector": "workspace:*", + "@data-recipe/recipe-core": "workspace:*", + "@data-recipe/recipe-runner": "workspace:*", + "@data-recipe/skill-builder": "workspace:*", + "@data-recipe/skill-exporter": "workspace:*" + }, + "devDependencies": { + "rimraf": "^6.0.1" + } +} diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json new file mode 100644 index 0000000..42df91c --- /dev/null +++ b/apps/extension/public/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "DataRecipe / AI 有数", + "description": "发现网页背后的数据来源,并生成可复用的数据配方草稿。", + "version": "0.1.0", + "action": { + "default_title": "AI 有数" + }, + "background": { + "service_worker": "background.global.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.global.js"], + "run_at": "document_start" + } + ], + "side_panel": { + "default_path": "sidepanel.html" + }, + "permissions": ["activeTab", "sidePanel", "tabs", "scripting", "storage"], + "host_permissions": [""], + "web_accessible_resources": [ + { + "resources": ["page-hook.global.js"], + "matches": [""] + } + ] +} + diff --git a/apps/extension/public/sidepanel.css b/apps/extension/public/sidepanel.css new file mode 100644 index 0000000..742f00e --- /dev/null +++ b/apps/extension/public/sidepanel.css @@ -0,0 +1,303 @@ +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #202124; + background: #f7f8fa; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 360px; +} + +button { + border: 1px solid #c9cdd4; + border-radius: 6px; + background: #ffffff; + color: #202124; + cursor: pointer; + font: inherit; + min-height: 36px; + padding: 0 12px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.primary { + border-color: #0f766e; + background: #0f766e; + color: #ffffff; +} + +.app { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 100vh; + padding: 16px; +} + +.header { + align-items: center; + display: flex; + justify-content: space-between; + gap: 12px; +} + +.eyebrow { + color: #5f6368; + font-size: 12px; + margin: 0 0 4px; +} + +h1 { + font-size: 22px; + line-height: 1.2; + margin: 0; +} + +.status { + border: 1px solid #d7dae0; + border-radius: 999px; + color: #5f6368; + font-size: 12px; + padding: 4px 9px; + white-space: nowrap; +} + +.status.active { + border-color: #14b8a6; + color: #0f766e; +} + +.toolbar { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; +} + +.summary { + align-items: baseline; + background: #ffffff; + border: 1px solid #e1e4e8; + border-radius: 8px; + display: flex; + gap: 6px; + padding: 12px; +} + +.summary strong { + font-size: 24px; +} + +.layout { + display: grid; + gap: 12px; +} + +.request-list, +.detail { + background: #ffffff; + border: 1px solid #e1e4e8; + border-radius: 8px; + min-width: 0; + padding: 12px; +} + +.section-title { + color: #5f6368; + font-size: 12px; + font-weight: 600; + margin-bottom: 10px; +} + +.list-empty, +.empty-detail { + color: #6b7280; + font-size: 13px; + line-height: 1.5; +} + +.request-item { + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + display: grid; + gap: 5px; + margin-bottom: 8px; + padding: 10px; + text-align: left; + width: 100%; +} + +.request-item.active { + border-color: #0f766e; + box-shadow: 0 0 0 1px #0f766e inset; +} + +.request-title { + color: #111827; + font-size: 13px; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + color: #6b7280; + display: flex; + flex-wrap: wrap; + font-size: 12px; + gap: 6px; +} + +.pill { + background: #eef2f7; + border-radius: 999px; + padding: 2px 7px; +} + +.finding { + color: #374151; + font-size: 13px; + line-height: 1.5; +} + +.detail-block { + margin-bottom: 14px; +} + +.detail-block h2 { + font-size: 14px; + margin: 0 0 8px; +} + +.key-value { + display: grid; + gap: 8px; +} + +.key-value dt { + color: #6b7280; + font-size: 12px; +} + +.key-value dd { + font-size: 13px; + margin: 2px 0 0; + overflow-wrap: anywhere; +} + +pre { + background: #111827; + border-radius: 6px; + color: #f9fafb; + font-size: 12px; + line-height: 1.45; + margin: 0; + max-height: 260px; + overflow: auto; + padding: 10px; + white-space: pre-wrap; +} + +details { + border-top: 1px solid #e5e7eb; + padding-top: 10px; +} + +summary { + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.recipe-draft { + display: grid; + gap: 10px; +} + +.copy-button { + justify-self: start; +} + +.run-preview { + display: grid; + gap: 10px; +} + +.field-editor { + display: grid; + gap: 8px; +} + +.field-row { + display: grid; + gap: 4px; +} + +.field-row input { + border: 1px solid #c9cdd4; + border-radius: 6px; + font: inherit; + min-height: 34px; + padding: 6px 8px; + width: 100%; +} + +.field-row span { + color: #6b7280; + font-size: 12px; + overflow-wrap: anywhere; +} + +.skill-editor { + display: grid; + gap: 10px; +} + +.skill-field { + display: grid; + gap: 5px; +} + +.skill-field span { + color: #6b7280; + font-size: 12px; + font-weight: 600; +} + +.skill-field input, +.skill-field textarea { + border: 1px solid #c9cdd4; + border-radius: 6px; + font: inherit; + padding: 7px 8px; + width: 100%; +} + +.skill-field textarea { + line-height: 1.45; + resize: vertical; +} + +.skill-package { + display: grid; + gap: 10px; +} + +.package-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/apps/extension/public/sidepanel.html b/apps/extension/public/sidepanel.html new file mode 100644 index 0000000..23bf639 --- /dev/null +++ b/apps/extension/public/sidepanel.html @@ -0,0 +1,43 @@ + + + + + + AI 有数 + + + +
+
+
+

DataRecipe

+

AI 有数

+
+ 未开始 +
+ +
+ + +
+ +
+ 0 + 个数据来源 +
+ +
+
+
发现的数据来源
+
还没有发现数据来源
+
+ +
+
详情
+
点击「开始发现」,然后在页面里触发一次查询。
+
+
+
+ + + diff --git a/apps/extension/scripts/copy-assets.mjs b/apps/extension/scripts/copy-assets.mjs new file mode 100644 index 0000000..63ec88a --- /dev/null +++ b/apps/extension/scripts/copy-assets.mjs @@ -0,0 +1,10 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const publicDir = join(root, "public"); +const distDir = join(root, "dist"); + +await mkdir(distDir, { recursive: true }); +await cp(publicDir, distDir, { recursive: true }); diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..11e95c6 --- /dev/null +++ b/apps/extension/src/background.ts @@ -0,0 +1,124 @@ +import type { CapturedRequest } from "@data-recipe/detector"; +import type { PanelEvent, PanelMessage, PanelSnapshot, RuntimeMessage, TabControlMessage } from "./messages"; + +const tabCaptures = new Map(); +const activeTabs = new Set(); +const panelPorts = new Set(); + +chrome.runtime.onInstalled.addListener(() => { + if (chrome.sidePanel?.setPanelBehavior) { + void chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + } +}); + +chrome.action.onClicked.addListener(async (tab) => { + if (tab.id && chrome.sidePanel?.open) { + await chrome.sidePanel.open({ tabId: tab.id }); + } +}); + +chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender) => { + if (message.type !== "captured-request" || !sender.tab?.id) { + return; + } + + const tabId = sender.tab.id; + const captures = tabCaptures.get(tabId) ?? []; + captures.unshift(message.capture); + tabCaptures.set(tabId, captures.slice(0, 100)); + + broadcast({ + type: "capture-added", + capture: message.capture, + snapshot: createSnapshot(tabId) + }); +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== "data-recipe-sidepanel") { + return; + } + + panelPorts.add(port); + port.onDisconnect.addListener(() => panelPorts.delete(port)); + port.onMessage.addListener((message: PanelMessage) => { + void handlePanelMessage(port, message); + }); + + void sendSnapshot(port); +}); + +async function handlePanelMessage(port: chrome.runtime.Port, message: PanelMessage): Promise { + const tabId = await getActiveTabId(); + + if (message.type === "get-snapshot") { + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (!tabId) { + send(port, { type: "snapshot", snapshot: createSnapshot(null) }); + return; + } + + if (message.type === "start-discovery") { + activeTabs.add(tabId); + await sendControlMessage(tabId, { type: "start-discovery" }); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (message.type === "stop-discovery") { + activeTabs.delete(tabId); + await sendControlMessage(tabId, { type: "stop-discovery" }); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + return; + } + + if (message.type === "clear-captures") { + tabCaptures.set(tabId, []); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); + } +} + +async function sendSnapshot(port: chrome.runtime.Port): Promise { + const tabId = await getActiveTabId(); + send(port, { type: "snapshot", snapshot: createSnapshot(tabId) }); +} + +async function getActiveTabId(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab?.id ?? null; +} + +async function sendControlMessage(tabId: number, message: TabControlMessage): Promise { + try { + await chrome.tabs.sendMessage(tabId, message); + } catch { + await chrome.scripting.executeScript({ + target: { tabId }, + files: ["content.global.js"] + }); + await chrome.tabs.sendMessage(tabId, message); + } +} + +function createSnapshot(tabId: number | null): PanelSnapshot { + return { + tabId, + state: tabId && activeTabs.has(tabId) ? "active" : "idle", + captures: tabId ? tabCaptures.get(tabId) ?? [] : [] + }; +} + +function broadcast(event: PanelEvent): void { + panelPorts.forEach((port) => send(port, event)); +} + +function send(port: chrome.runtime.Port, event: PanelEvent): void { + try { + port.postMessage(event); + } catch { + panelPorts.delete(port); + } +} diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts new file mode 100644 index 0000000..72dfaf6 --- /dev/null +++ b/apps/extension/src/content.ts @@ -0,0 +1,59 @@ +import { formatPreview, parseQueryFromUrl, tryParseJson, type CapturedRequest } from "@data-recipe/detector"; +import type { PageCapturedMessage, PageControlMessage, TabControlMessage } from "./messages"; + +const scriptId = "data-recipe-page-hook"; + +injectPageHook(); + +chrome.runtime.onMessage.addListener((message: TabControlMessage) => { + if (message.type === "start-discovery" || message.type === "stop-discovery") { + postControl(message.type); + } +}); + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window || event.data?.source !== "data-recipe-page-hook") { + return; + } + + if (event.data.type !== "captured-request") { + return; + } + + const payload = event.data.payload; + const responseBody = + typeof payload.responseBody === "string" ? tryParseJson(payload.responseBody) : payload.responseBody; + + const capture: CapturedRequest = { + ...payload, + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + pageUrl: window.location.href, + query: parseQueryFromUrl(payload.url), + responseBody, + responsePreview: formatPreview(responseBody), + capturedAt: Date.now() + }; + + void chrome.runtime.sendMessage({ type: "captured-request", capture }); +}); + +function injectPageHook(): void { + if (document.getElementById(scriptId)) { + return; + } + + const script = document.createElement("script"); + script.id = scriptId; + script.src = chrome.runtime.getURL("page-hook.global.js"); + script.async = false; + (document.documentElement || document.head).appendChild(script); + script.remove(); +} + +function postControl(type: PageControlMessage["type"]): void { + const message: PageControlMessage = { + source: "data-recipe-content", + type + }; + window.postMessage(message, "*"); +} diff --git a/apps/extension/src/messages.ts b/apps/extension/src/messages.ts new file mode 100644 index 0000000..633d65a --- /dev/null +++ b/apps/extension/src/messages.ts @@ -0,0 +1,38 @@ +import type { CapturedRequest } from "@data-recipe/detector"; + +export type DiscoveryState = "idle" | "active"; + +export interface PanelSnapshot { + tabId: number | null; + state: DiscoveryState; + captures: CapturedRequest[]; +} + +export type RuntimeMessage = + | { type: "captured-request"; capture: CapturedRequest } + | { type: "content-ready" }; + +export type TabControlMessage = + | { type: "start-discovery" } + | { type: "stop-discovery" }; + +export type PanelMessage = + | { type: "get-snapshot" } + | { type: "start-discovery" } + | { type: "stop-discovery" } + | { type: "clear-captures" }; + +export type PanelEvent = + | { type: "snapshot"; snapshot: PanelSnapshot } + | { type: "capture-added"; capture: CapturedRequest; snapshot: PanelSnapshot }; + +export interface PageCapturedMessage { + source: "data-recipe-page-hook"; + type: "captured-request"; + payload: Omit; +} + +export interface PageControlMessage { + source: "data-recipe-content"; + type: "start-discovery" | "stop-discovery"; +} diff --git a/apps/extension/src/page-hook.ts b/apps/extension/src/page-hook.ts new file mode 100644 index 0000000..cec3135 --- /dev/null +++ b/apps/extension/src/page-hook.ts @@ -0,0 +1,203 @@ +import type { PageControlMessage } from "./messages"; + +type CapturePayload = { + url: string; + method: string; + status: number; + requestBody: unknown; + responseBody: unknown; + responsePreview: string; + transport: "fetch" | "xhr"; +}; + +const state = { + enabled: false, + fetchPatched: false, + xhrPatched: false +}; + +patchFetch(); +patchXhr(); + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window || event.data?.source !== "data-recipe-content") { + return; + } + + state.enabled = event.data.type === "start-discovery"; +}); + +function patchFetch(): void { + if (state.fetchPatched || !window.fetch) { + return; + } + + state.fetchPatched = true; + const originalFetch = window.fetch.bind(window); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const requestInfo = await readFetchRequest(input, init); + const response = await originalFetch(input, init); + + if (state.enabled) { + void readResponseBody(response.clone()).then((responseBody) => { + emitCapture({ + ...requestInfo, + status: response.status, + responseBody, + responsePreview: makePreview(responseBody), + transport: "fetch" + }); + }); + } + + return response; + }; +} + +function patchXhr(): void { + if (state.xhrPatched || !window.XMLHttpRequest) { + return; + } + + state.xhrPatched = true; + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function open(method: string, url: string | URL): void { + this.__dataRecipe = { + method, + url: String(url), + requestBody: null + }; + return originalOpen.apply(this, arguments as unknown as Parameters); + }; + + XMLHttpRequest.prototype.send = function send(body?: Document | XMLHttpRequestBodyInit | null): void { + if (this.__dataRecipe) { + this.__dataRecipe.requestBody = serializeBody(body); + } + + this.addEventListener("loadend", () => { + if (!state.enabled || !this.__dataRecipe) { + return; + } + + const responseBody = parseJsonLike(this.responseText); + emitCapture({ + url: toAbsoluteUrl(this.__dataRecipe.url), + method: this.__dataRecipe.method, + status: this.status, + requestBody: this.__dataRecipe.requestBody, + responseBody, + responsePreview: makePreview(responseBody), + transport: "xhr" + }); + }); + + return originalSend.apply(this, arguments as unknown as Parameters); + }; +} + +async function readFetchRequest(input: RequestInfo | URL, init?: RequestInit): Promise> { + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const url = input instanceof Request ? input.url : String(input); + const requestBody = init?.body !== undefined ? serializeBody(init.body) : await readRequestClone(input); + + return { + url: toAbsoluteUrl(url), + method: method.toUpperCase(), + requestBody + }; +} + +async function readRequestClone(input: RequestInfo | URL): Promise { + if (!(input instanceof Request)) { + return null; + } + + try { + return parseJsonLike(await input.clone().text()); + } catch { + return null; + } +} + +async function readResponseBody(response: Response): Promise { + try { + return parseJsonLike(await response.text()); + } catch { + return null; + } +} + +function serializeBody(body: unknown): unknown { + if (body === undefined || body === null) { + return null; + } + + if (typeof body === "string") { + return parseJsonLike(body); + } + + if (body instanceof URLSearchParams) { + return Object.fromEntries(body.entries()); + } + + if (body instanceof FormData) { + return Object.fromEntries(Array.from(body.entries()).map(([key, value]) => [key, String(value)])); + } + + if (body instanceof Blob || body instanceof ArrayBuffer) { + return "[binary body]"; + } + + return String(body); +} + +function parseJsonLike(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function makePreview(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return text.length > 1800 ? `${text.slice(0, 1800)}...` : text; +} + +function toAbsoluteUrl(url: string): string { + try { + return new URL(url, window.location.href).href; + } catch { + return url; + } +} + +function emitCapture(payload: CapturePayload): void { + window.postMessage( + { + source: "data-recipe-page-hook", + type: "captured-request", + payload + }, + "*" + ); +} + +declare global { + interface XMLHttpRequest { + __dataRecipe?: { + method: string; + url: string; + requestBody: unknown; + }; + } +} diff --git a/apps/extension/src/sidepanel.ts b/apps/extension/src/sidepanel.ts new file mode 100644 index 0000000..3ff6fb4 --- /dev/null +++ b/apps/extension/src/sidepanel.ts @@ -0,0 +1,497 @@ +import { buildRecipeFromCapture, summarizeResponse, type CapturedRequest } from "@data-recipe/detector"; +import { runRecipeOnSample } from "@data-recipe/recipe-runner"; +import { buildDataSkillPackage, type DataSkillPackage } from "@data-recipe/skill-builder"; +import { exportDataSkillPackageAsJson, validateDataSkillPackage } from "@data-recipe/skill-exporter"; +import type { PanelEvent, PanelSnapshot } from "./messages"; + +interface RecipeMetadata { + displayName?: string; + description?: string; + useCases?: string[]; +} +const FIELD_LABELS_STORAGE_KEY = "dataRecipeFieldLabels"; +const RECIPE_META_STORAGE_KEY = "dataRecipeMetadata"; + +const port = chrome.runtime.connect({ name: "data-recipe-sidepanel" }); + +const state: { + snapshot: PanelSnapshot; + selectedId: string | null; + fieldLabels: Record>; + recipeMetadata: Record; +} = { + snapshot: { + tabId: null, + state: "idle", + captures: [] + }, + selectedId: null, + fieldLabels: {}, + recipeMetadata: {} +}; + +const elements = { + statusBadge: getElement("statusBadge"), + startButton: getElement("startButton"), + stopButton: getElement("stopButton"), + captureCount: getElement("captureCount"), + requestList: getElement("requestList"), + requestDetail: getElement("requestDetail") +}; + +elements.startButton.addEventListener("click", () => port.postMessage({ type: "start-discovery" })); +elements.stopButton.addEventListener("click", () => port.postMessage({ type: "stop-discovery" })); + +port.onMessage.addListener((event: PanelEvent) => { + if (event.type === "snapshot") { + applySnapshot(event.snapshot); + return; + } + + if (event.type === "capture-added") { + applySnapshot(event.snapshot); + state.selectedId = event.capture.id; + render(); + } +}); + +void loadStoredEdits(); +port.postMessage({ type: "get-snapshot" }); + +function applySnapshot(snapshot: PanelSnapshot): void { + state.snapshot = snapshot; + if (!state.selectedId && snapshot.captures[0]) { + state.selectedId = snapshot.captures[0].id; + } + render(); +} + +function render(): void { + const isActive = state.snapshot.state === "active"; + elements.statusBadge.textContent = isActive ? "发现中" : "未开始"; + elements.statusBadge.classList.toggle("active", isActive); + elements.startButton.disabled = isActive; + elements.stopButton.disabled = !isActive; + elements.captureCount.textContent = String(state.snapshot.captures.length); + + renderList(); + renderDetail(); +} + +function renderList(): void { + const captures = state.snapshot.captures; + elements.requestList.innerHTML = ""; + + if (captures.length === 0) { + elements.requestList.className = "list-empty"; + elements.requestList.textContent = "还没有发现数据来源"; + return; + } + + elements.requestList.className = ""; + + captures.forEach((capture) => { + const detection = summarizeResponse(capture.responseBody); + const button = document.createElement("button"); + button.type = "button"; + button.className = `request-item${capture.id === state.selectedId ? " active" : ""}`; + button.addEventListener("click", () => { + state.selectedId = capture.id; + render(); + }); + + const title = document.createElement("div"); + title.className = "request-title"; + title.textContent = readableUrl(capture.url); + + const meta = document.createElement("div"); + meta.className = "meta"; + meta.append(pill(capture.method), pill(String(capture.status)), pill(detection.looksLikeList ? "像列表数据" : "普通数据")); + + button.append(title, meta); + elements.requestList.append(button); + }); +} + +function renderDetail(): void { + const capture = state.snapshot.captures.find((item) => item.id === state.selectedId); + + if (!capture) { + elements.requestDetail.className = "empty-detail"; + elements.requestDetail.textContent = "点击「开始发现」,然后在页面里触发一次查询。"; + return; + } + + const detection = summarizeResponse(capture.responseBody); + const recipe = applyRecipeEdits(capture.url, buildRecipeFromCapture(capture)); + const runResult = runRecipeOnSample(recipe, capture.responseBody); + const skillPackage = buildDataSkillPackage({ recipe, userGoal: recipe.description }); + + elements.requestDetail.className = ""; + elements.requestDetail.innerHTML = ""; + elements.requestDetail.append( + block("基础信息", keyValues([ + ["数据来源", capture.url], + ["请求方式", capture.method], + ["状态", String(capture.status)], + ["发现时间", new Date(capture.capturedAt).toLocaleString()] + ])), + block("智能判断", findingList([ + detection.looksLikeList ? "看起来像列表数据" : "暂未判断为列表数据", + detection.hasArray ? `包含数组:${detection.arrayPaths.join(", ")}` : "暂未发现数组", + detection.keywordPaths.length > 0 + ? `可能包含总数、页码或列表字段:${detection.keywordPaths.join(", ")}` + : "暂未发现 total / count / page / records / list 等字段" + ])), + block("查询条件", pre(JSON.stringify({ query: capture.query, body: capture.requestBody ?? {} }, null, 2))), + parameterPreviewBlock(recipe), + fieldPreviewBlock(recipe), + block("返回预览", pre(capture.responsePreview || "无可展示内容")), + recipeMetadataBlock(recipe), + recipeDraftBlock(recipe), + skillPackageBlock(skillPackage), + runPreviewBlock(runResult), + advancedInfo(capture, recipe) + ); +} + +function applyRecipeEdits(sourceKey: string, recipe: ReturnType): ReturnType { + const labels = state.fieldLabels[sourceKey] ?? {}; + const metadata = state.recipeMetadata[sourceKey]; + return { + ...recipe, + displayName: metadata?.displayName ?? recipe.displayName, + description: metadata?.description ?? recipe.description, + useCases: metadata?.useCases?.length ? metadata.useCases : recipe.useCases, + fields: recipe.fields.map((field) => ({ + ...field, + displayName: labels[field.path] ?? field.displayName, + description: labels[field.path] ? "用户确认" : field.description + })) + }; +} +function parameterPreviewBlock(recipe: ReturnType): HTMLElement { + const parameters = [...recipe.request.queryFields, ...recipe.request.bodyFields]; + + if (parameters.length === 0) { + return block("查询条件说明", findingList(["暂未发现明确的查询条件。"])); + } + + return block( + "查询条件说明", + findingList(parameters.slice(0, 12).map((item) => `${item.displayName}:${item.type}${item.sample === undefined ? "" : `,样例 ${formatInlineValue(item.sample)}`}`)) + ); +} + +function fieldPreviewBlock(recipe: ReturnType): HTMLElement { + if (recipe.fields.length === 0 || !state.selectedId) { + return block("返回字段", findingList(["暂未从当前数据中识别出稳定字段。"])); + } + + const wrapper = document.createElement("div"); + wrapper.className = "field-editor"; + + recipe.fields.slice(0, 16).forEach((field) => { + const row = document.createElement("label"); + row.className = "field-row"; + + const input = document.createElement("input"); + input.value = field.displayName; + input.placeholder = field.name; + input.addEventListener("input", () => { + const sourceKey = recipe.source.apiUrl; + const labels = state.fieldLabels[sourceKey] ?? {}; + const label = input.value.trim() || field.name; + labels[field.path] = label; + state.fieldLabels[sourceKey] = labels; + field.displayName = label; + field.description = "用户确认"; + void saveStoredEdits(); + }); + + const meta = document.createElement("span"); + meta.textContent = `${field.name} · ${field.type}${field.sample === undefined ? "" : ` · 样例 ${formatInlineValue(field.sample)}`}`; + + row.append(input, meta); + wrapper.append(row); + }); + + return block("返回字段", wrapper); +} + +function recipeMetadataBlock(recipe: ReturnType): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "skill-editor"; + + const nameLabel = document.createElement("label"); + nameLabel.className = "skill-field"; + const nameTitle = document.createElement("span"); + nameTitle.textContent = "数据技能名称"; + const nameInput = document.createElement("input"); + nameInput.value = recipe.displayName; + nameInput.addEventListener("input", () => { + const metadata = state.recipeMetadata[recipe.source.apiUrl] ?? {}; + metadata.displayName = nameInput.value.trim() || "数据来源"; + state.recipeMetadata[recipe.source.apiUrl] = metadata; + recipe.displayName = metadata.displayName; + void saveStoredEdits(); + }); + nameLabel.append(nameTitle, nameInput); + + const descriptionLabel = document.createElement("label"); + descriptionLabel.className = "skill-field"; + const descriptionTitle = document.createElement("span"); + descriptionTitle.textContent = "用途说明"; + const descriptionInput = document.createElement("textarea"); + descriptionInput.rows = 3; + descriptionInput.value = recipe.description; + descriptionInput.addEventListener("input", () => { + const metadata = state.recipeMetadata[recipe.source.apiUrl] ?? {}; + metadata.description = descriptionInput.value.trim(); + metadata.useCases = splitUseCases(descriptionInput.value); + state.recipeMetadata[recipe.source.apiUrl] = metadata; + recipe.description = metadata.description ?? ""; + recipe.useCases = metadata.useCases ?? []; + void saveStoredEdits(); + }); + descriptionLabel.append(descriptionTitle, descriptionInput); + + wrapper.append(nameLabel, descriptionLabel); + return block("数据技能信息", wrapper); +} +function skillPackageBlock(skillPackage: DataSkillPackage): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "skill-package"; + + const validation = validateDataSkillPackage(skillPackage); + const summary = document.createElement("div"); + summary.className = "finding"; + summary.textContent = validation.ok + ? `已生成 ${skillPackage.files.length} 个文件,技能包可以导出。` + : `技能包还缺少:${validation.missingFiles.join("、")}`; + + const actions = document.createElement("div"); + actions.className = "package-actions"; + + const skillFile = skillPackage.files.find((file) => file.path === "SKILL.md"); + const recipeFile = skillPackage.files.find((file) => file.path === "recipe.json"); + + actions.append(exportPackageButton(skillPackage)); + + if (skillFile) { + actions.append(copyContentButton("复制 SKILL.md", skillFile.content)); + } + if (recipeFile) { + actions.append(copyContentButton("复制 recipe.json", recipeFile.content)); + } + + const details = document.createElement("details"); + const detailsSummary = document.createElement("summary"); + detailsSummary.textContent = "预览 SKILL.md"; + details.append(detailsSummary, pre(skillFile?.content ?? "暂未生成 SKILL.md")); + + wrapper.append(summary, actions, details); + return block("技能包预览", wrapper); +} + +function exportPackageButton(skillPackage: DataSkillPackage): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "primary"; + button.textContent = "导出技能包"; + button.addEventListener("click", () => { + downloadTextFile(exportDataSkillPackageAsJson(skillPackage)); + }); + return button; +} + +function downloadTextFile(file: ReturnType): void { + const blob = new Blob([file.content], { type: file.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.fileName; + link.click(); + URL.revokeObjectURL(url); +} +function copyContentButton(label: string, content: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "copy-button"; + button.textContent = label; + button.addEventListener("click", () => { + void copyText(content, button); + }); + return button; +} +function runPreviewBlock(runResult: ReturnType): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "run-preview"; + + const summary = document.createElement("div"); + summary.className = "finding"; + summary.textContent = runResult.message; + wrapper.append(summary); + + if (runResult.previewRows.length > 0) { + wrapper.append(pre(JSON.stringify(runResult.previewRows, null, 2))); + } + + return block("试运行结果", wrapper); +} + +function recipeDraftBlock(recipe: unknown): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "recipe-draft"; + + const summary = document.createElement("div"); + summary.className = "finding"; + summary.textContent = "已根据当前数据来源生成一份可复用的数据技能草稿。"; + + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.className = "copy-button"; + copyButton.textContent = "复制 JSON"; + copyButton.addEventListener("click", () => { + void copyText(JSON.stringify(recipe, null, 2), copyButton); + }); + + wrapper.append(summary, copyButton); + return block("数据技能草稿", wrapper); +} + +function advancedInfo(capture: CapturedRequest, recipe: unknown): HTMLElement { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "高级信息"; + const content = document.createElement("div"); + content.className = "detail-block"; + content.append( + block("传输方式", keyValues([["类型", capture.transport]])), + block("数据技能 JSON", pre(JSON.stringify(recipe, null, 2))) + ); + details.append(summary, content); + return details; +} + +function block(titleText: string, content: HTMLElement): HTMLElement { + const section = document.createElement("section"); + section.className = "detail-block"; + const title = document.createElement("h2"); + title.textContent = titleText; + section.append(title, content); + return section; +} + +function keyValues(items: Array<[string, string]>): HTMLElement { + const list = document.createElement("dl"); + list.className = "key-value"; + items.forEach(([key, value]) => { + const wrapper = document.createElement("div"); + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = key; + dd.textContent = value; + wrapper.append(dt, dd); + list.append(wrapper); + }); + return list; +} + +function findingList(items: string[]): HTMLElement { + const container = document.createElement("div"); + container.className = "finding"; + items.forEach((item) => { + const line = document.createElement("div"); + line.textContent = item; + container.append(line); + }); + return container; +} + +function pre(text: string): HTMLElement { + const element = document.createElement("pre"); + element.textContent = text; + return element; +} + +function pill(text: string): HTMLElement { + const element = document.createElement("span"); + element.className = "pill"; + element.textContent = text; + return element; +} + +function readableUrl(urlValue: string): string { + try { + const url = new URL(urlValue); + return `${url.hostname}${url.pathname}`; + } catch { + return urlValue; + } +} + +async function loadStoredEdits(): Promise { + try { + const stored = await chrome.storage.local.get([FIELD_LABELS_STORAGE_KEY, RECIPE_META_STORAGE_KEY]); + const labels = stored[FIELD_LABELS_STORAGE_KEY]; + const metadata = stored[RECIPE_META_STORAGE_KEY]; + if (labels && typeof labels === "object" && !Array.isArray(labels)) { + state.fieldLabels = labels as Record>; + } + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + state.recipeMetadata = metadata as Record; + } + render(); + } catch { + // Local edits remain usable even if extension storage is unavailable. + } +} + +async function saveStoredEdits(): Promise { + try { + await chrome.storage.local.set({ + [FIELD_LABELS_STORAGE_KEY]: state.fieldLabels, + [RECIPE_META_STORAGE_KEY]: state.recipeMetadata + }); + } catch { + // The current in-memory edit is still reflected in the copied draft. + } +} +function splitUseCases(value: string): string[] { + return value + .split(/[\n,,;;]+/) + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 8); +} +function formatInlineValue(value: unknown): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + if (!text) { + return "空"; + } + return text.length > 48 ? `${text.slice(0, 48)}...` : text; +} +function getElement(id: string): T { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Missing element: ${id}`); + } + return element as T; +} + +async function copyText(text: string, button: HTMLButtonElement): Promise { + const originalText = button.textContent ?? "复制 JSON"; + + try { + await navigator.clipboard.writeText(text); + button.textContent = "已复制"; + } catch { + button.textContent = "复制失败"; + } + + window.setTimeout(() => { + button.textContent = originalText; + }, 1600); +} + diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..7dd6ed9 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["chrome"], + "noEmit": true + }, + "include": ["src", "scripts"] +} diff --git a/docs/test-page.html b/docs/test-page.html new file mode 100644 index 0000000..9086569 --- /dev/null +++ b/docs/test-page.html @@ -0,0 +1,86 @@ + + + + + + DataRecipe 测试页 + + + +
+

DataRecipe 测试页

+

加载插件后打开侧边栏,点击「开始发现」,再点击下面的按钮触发 fetch 或 XHR 请求。

+ + +
等待请求...
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba199e6 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "data-recipe", + "version": "0.1.0", + "private": true, + "description": "Turn web page queries and API responses into reusable data recipes for AI agents.", + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "dev:extension": "pnpm --filter @data-recipe/extension dev", + "build:extension": "pnpm --filter @data-recipe/extension build", + "validate:skill-package": "node scripts/validate-data-skill-package.mjs" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "packageManager": "pnpm@9.7.0" +} diff --git a/packages/detector/package.json b/packages/detector/package.json new file mode 100644 index 0000000..4c9ded6 --- /dev/null +++ b/packages/detector/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/detector", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/detector/src/index.ts b/packages/detector/src/index.ts new file mode 100644 index 0000000..a9f479c --- /dev/null +++ b/packages/detector/src/index.ts @@ -0,0 +1,210 @@ +import { + createDataRecipeDraft, + type DataRecipe, + inferDataValueType, + type DataRecipeField, + type JsonObject +} from "@data-recipe/recipe-core"; + +export interface CapturedRequest { + id: string; + pageUrl: string; + url: string; + method: string; + status: number; + requestBody: unknown; + query: JsonObject; + responseBody: unknown; + responsePreview: string; + capturedAt: number; + transport: "fetch" | "xhr"; +} + +export interface ResponseDetection { + isJson: boolean; + looksLikeList: boolean; + hasArray: boolean; + arrayPaths: string[]; + listPath: string; + totalPath: string; + keywordPaths: string[]; + fields: DataRecipeField[]; +} + +const TOTAL_KEYS = new Set(["total", "count", "totalCount", "total_count", "recordCount", "record_count"]); +const LIST_KEYS = new Set(["records", "list", "items", "rows", "data", "result"]); +const PAGE_KEYS = new Set(["page", "pageNo", "pageNum", "page_number", "current", "currentPage", "pageSize"]); + +export function parseQueryFromUrl(urlValue: string): JsonObject { + try { + const url = new URL(urlValue); + const query: JsonObject = {}; + url.searchParams.forEach((value, key) => { + if (query[key] === undefined) { + query[key] = value; + } else if (Array.isArray(query[key])) { + (query[key] as string[]).push(value); + } else { + query[key] = [query[key], value]; + } + }); + return query; + } catch { + return {}; + } +} + +export function tryParseJson(text: string): unknown { + if (!text.trim()) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export function summarizeResponse(responseBody: unknown): ResponseDetection { + const isJson = responseBody !== null && typeof responseBody === "object"; + const arrayPaths: string[] = []; + const keywordPaths: string[] = []; + const totalPaths: string[] = []; + + if (isJson) { + walk(responseBody, "$", (path, value, key) => { + if (Array.isArray(value)) { + arrayPaths.push(path); + } + + if (key && (TOTAL_KEYS.has(key) || LIST_KEYS.has(key) || PAGE_KEYS.has(key))) { + keywordPaths.push(path); + } + + if (key && TOTAL_KEYS.has(key) && typeof value !== "object") { + totalPaths.push(path); + } + }); + } + + const listPath = pickListPath(responseBody, arrayPaths); + const fields = inferFieldsAtPath(responseBody, listPath); + + return { + isJson, + looksLikeList: arrayPaths.length > 0 && fields.length > 0, + hasArray: arrayPaths.length > 0, + arrayPaths, + listPath, + totalPath: totalPaths[0] ?? "", + keywordPaths, + fields + }; +} + +export function buildRecipeFromCapture(capture: CapturedRequest): DataRecipe { + const detection = summarizeResponse(capture.responseBody); + + return createDataRecipeDraft({ + pageUrl: capture.pageUrl, + apiUrl: capture.url, + method: capture.method, + query: capture.query, + body: capture.requestBody ?? {}, + responseType: detection.looksLikeList ? "list" : detection.isJson ? "object" : "unknown", + listPath: detection.listPath, + totalPath: detection.totalPath, + fields: detection.fields + }); +} + +export function formatPreview(value: unknown, maxLength = 1800): string { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + + if (!text) { + return ""; + } + + return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; +} + +function pickListPath(responseBody: unknown, arrayPaths: string[]): string { + if (arrayPaths.length === 0) { + return ""; + } + + const preferred = arrayPaths.find((path) => { + const key = path.split(".").at(-1) ?? ""; + return LIST_KEYS.has(key); + }); + + return preferred ?? arrayPaths[0] ?? ""; +} + +function inferFieldsAtPath(responseBody: unknown, listPath: string): DataRecipeField[] { + const target = getByPath(responseBody, listPath); + const sample = Array.isArray(target) ? target.find((item) => item && typeof item === "object") : null; + + if (!sample || typeof sample !== "object" || Array.isArray(sample)) { + return []; + } + + return Object.entries(sample).map(([key, value]) => ({ + name: key, + displayName: key, + path: `${listPath}.${key}`, + type: inferDataValueType(value), + sample: simplifySample(value), + description: "待确认" + })); +} + +function getByPath(value: unknown, path: string): unknown { + if (!path || path === "$") { + return value; + } + + return path + .replace(/^\$\./, "") + .split(".") + .filter(Boolean) + .reduce((current, part) => { + if (current && typeof current === "object" && part in current) { + return (current as Record)[part]; + } + return undefined; + }, value); +} + +function walk(value: unknown, path: string, visit: (path: string, value: unknown, key?: string) => void): void { + visit(path, value, path.split(".").at(-1)); + + if (!value || typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + value.slice(0, 3).forEach((item, index) => walk(item, `${path}[${index}]`, visit)); + return; + } + + Object.entries(value).forEach(([key, child]) => { + const childPath = path === "$" ? `$.${key}` : `${path}.${key}`; + walk(child, childPath, visit); + }); +} + +function simplifySample(value: unknown): unknown { + if (Array.isArray(value)) { + return value.slice(0, 2); + } + + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).slice(0, 5)); + } + + return value; +} + + diff --git a/packages/detector/tsconfig.json b/packages/detector/tsconfig.json new file mode 100644 index 0000000..97fb707 --- /dev/null +++ b/packages/detector/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../recipe-core" + } + ] +} diff --git a/packages/recipe-core/package.json b/packages/recipe-core/package.json new file mode 100644 index 0000000..a70786b --- /dev/null +++ b/packages/recipe-core/package.json @@ -0,0 +1,18 @@ +{ + "name": "@data-recipe/recipe-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/recipe-core/src/index.ts b/packages/recipe-core/src/index.ts new file mode 100644 index 0000000..ec2de60 --- /dev/null +++ b/packages/recipe-core/src/index.ts @@ -0,0 +1,172 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | string; + +export type JsonObject = Record; + +export type DataValueType = "string" | "number" | "boolean" | "object" | "array" | "null" | "unknown"; + +export interface DataRecipe { + name: string; + displayName: string; + description: string; + useCases: string[]; + source: DataRecipeSource; + request: DataRecipeRequest; + response: DataRecipeResponse; + pagination: DataRecipePagination; + fields: DataRecipeField[]; +} + +export interface DataRecipeSource { + type: "web_api"; + pageUrl: string; + apiUrl: string; + method: HttpMethod; +} + +export interface DataRecipeRequest { + query: JsonObject; + body: unknown; + queryFields: DataRecipeParameter[]; + bodyFields: DataRecipeParameter[]; +} + +export interface DataRecipeParameter { + name: string; + displayName: string; + path: string; + type: DataValueType; + required: boolean; + sample?: unknown; +} + +export interface DataRecipeResponse { + type: "list" | "object" | "unknown"; + listPath: string; + totalPath: string; +} + +export interface DataRecipePagination { + type: "page_number" | "cursor" | "unknown"; + pageParam: string; + pageSizeParam: string; + totalPath: string; +} + +export interface DataRecipeField { + name: string; + displayName: string; + path: string; + type: DataValueType; + sample?: unknown; + description: string; +} + +export interface CreateRecipeInput { + pageUrl: string; + apiUrl: string; + method: HttpMethod; + query: JsonObject; + body: unknown; + queryFields?: DataRecipeParameter[]; + bodyFields?: DataRecipeParameter[]; + responseType: DataRecipeResponse["type"]; + listPath: string; + totalPath: string; + fields: DataRecipeField[]; +} + +export function createDataRecipeDraft(input: CreateRecipeInput): DataRecipe { + const name = buildRecipeName(input.apiUrl); + + return { + name, + displayName: "数据来源", + description: "用于获取和预览这个数据来源中的列表数据。", + useCases: ["查看数据明细", "汇总和分析列表数据"], + source: { + type: "web_api", + pageUrl: input.pageUrl, + apiUrl: input.apiUrl, + method: input.method + }, + request: { + query: input.query, + body: input.body ?? {}, + queryFields: input.queryFields ?? inferParameters(input.query, "query"), + bodyFields: input.bodyFields ?? inferParameters(input.body, "body") + }, + response: { + type: input.responseType, + listPath: input.listPath, + totalPath: input.totalPath + }, + pagination: { + type: "page_number", + pageParam: "", + pageSizeParam: "", + totalPath: input.totalPath + }, + fields: input.fields + }; +} + +export function inferParameters(value: unknown, rootPath: "query" | "body"): DataRecipeParameter[] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + + return Object.entries(value).slice(0, 50).map(([name, sample]) => ({ + name, + displayName: name, + path: `${rootPath}.${name}`, + type: inferDataValueType(sample), + required: false, + sample: simplifySample(sample) + })); +} + +export function inferDataValueType(value: unknown): DataValueType { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + if (typeof value === "boolean") return "boolean"; + if (typeof value === "object") return "object"; + return "unknown"; +} + +function buildRecipeName(apiUrl: string): string { + try { + const url = new URL(apiUrl); + const pathPart = url.pathname + .split("/") + .filter(Boolean) + .slice(-2) + .join("_"); + return normalizeName(pathPart || url.hostname); + } catch { + return normalizeName(apiUrl || "data_source"); + } +} + +function normalizeName(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + + return normalized || "data_source"; +} + +function simplifySample(value: unknown): unknown { + if (Array.isArray(value)) { + return value.slice(0, 2); + } + + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).slice(0, 5)); + } + + return value; +} + diff --git a/packages/recipe-core/tsconfig.json b/packages/recipe-core/tsconfig.json new file mode 100644 index 0000000..8a1cac6 --- /dev/null +++ b/packages/recipe-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ] +} diff --git a/packages/recipe-runner/package.json b/packages/recipe-runner/package.json new file mode 100644 index 0000000..ef5c684 --- /dev/null +++ b/packages/recipe-runner/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/recipe-runner", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + } +} diff --git a/packages/recipe-runner/src/index.ts b/packages/recipe-runner/src/index.ts new file mode 100644 index 0000000..e924435 --- /dev/null +++ b/packages/recipe-runner/src/index.ts @@ -0,0 +1,72 @@ +import type { DataRecipe } from "@data-recipe/recipe-core"; + +export interface RecipeRunResult { + recipe: DataRecipe; + rows: unknown[]; + rowCount: number; + previewRows: unknown[]; + status: "ok" | "empty" | "unsupported"; + message: string; +} + +export interface RunRecipeOnSampleOptions { + previewLimit?: number; +} + +export function runRecipeOnSample( + recipe: DataRecipe, + responseBody: unknown, + options: RunRecipeOnSampleOptions = {} +): RecipeRunResult { + const previewLimit = options.previewLimit ?? 5; + const listValue = getByPath(responseBody, recipe.response.listPath); + + if (!Array.isArray(listValue)) { + return { + recipe, + rows: [], + rowCount: 0, + previewRows: [], + status: recipe.response.type === "list" ? "empty" : "unsupported", + message: recipe.response.type === "list" ? "没有从当前响应中读取到列表数据。" : "当前数据来源暂不适合按列表预览。" + }; + } + + const rows = listValue; + + return { + recipe, + rows, + rowCount: rows.length, + previewRows: rows.slice(0, previewLimit), + status: rows.length > 0 ? "ok" : "empty", + message: rows.length > 0 ? `已读取 ${rows.length} 条数据,下面展示前 ${Math.min(rows.length, previewLimit)} 条。` : "当前列表没有数据。" + }; +} + +function getByPath(value: unknown, path: string): unknown { + if (!path || path === "$") { + return value; + } + + const parts = path + .replace(/^\$\.?/, "") + .split(".") + .filter(Boolean); + + return parts.reduce((current, part) => { + if (!current || typeof current !== "object") { + return undefined; + } + + const arrayMatch = part.match(/^(.*)\[(\d+)\]$/); + if (arrayMatch) { + const [, key, indexText] = arrayMatch; + const container = key ? (current as Record)[key] : current; + const index = Number(indexText); + return Array.isArray(container) ? container[index] : undefined; + } + + return (current as Record)[part]; + }, value); +} diff --git a/packages/recipe-runner/tsconfig.json b/packages/recipe-runner/tsconfig.json new file mode 100644 index 0000000..97fb707 --- /dev/null +++ b/packages/recipe-runner/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../recipe-core" + } + ] +} diff --git a/packages/skill-builder/package.json b/packages/skill-builder/package.json new file mode 100644 index 0000000..d4bd030 --- /dev/null +++ b/packages/skill-builder/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/skill-builder", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/recipe-core": "workspace:*" + } +} diff --git a/packages/skill-builder/src/index.ts b/packages/skill-builder/src/index.ts new file mode 100644 index 0000000..26eded1 --- /dev/null +++ b/packages/skill-builder/src/index.ts @@ -0,0 +1,53 @@ +import type { DataRecipe } from "@data-recipe/recipe-core"; + +export interface DataSkillPackage { + packageName: string; + files: DataSkillPackageFile[]; +} + +export interface DataSkillPackageFile { + path: "SKILL.md" | "recipe.json" | "examples.md" | "README.md"; + content: string; +} + +export interface BuildDataSkillPackageInput { + recipe: DataRecipe; + userGoal?: string; +} + +export function buildDataSkillPackage(input: BuildDataSkillPackageInput): DataSkillPackage { + const packageName = input.recipe.name || "data-skill"; + const goal = input.userGoal?.trim() || input.recipe.description; + + return { + packageName, + files: [ + { path: "SKILL.md", content: buildSkillMarkdown(input.recipe, goal) }, + { path: "recipe.json", content: `${JSON.stringify(input.recipe, null, 2)}\n` }, + { path: "examples.md", content: buildExamplesMarkdown(input.recipe, goal) }, + { path: "README.md", content: buildReadmeMarkdown(input.recipe, goal) } + ] + }; +} + +function buildSkillMarkdown(recipe: DataRecipe, goal: string): string { + const fields = recipe.fields.length + ? recipe.fields.map((field) => `- ${field.displayName} (${field.name}): ${field.description}`).join("\n") + : "- 暂未确认返回字段。"; + const queryFields = [...recipe.request.queryFields, ...recipe.request.bodyFields]; + const queries = queryFields.length + ? queryFields.map((field) => `- ${field.displayName} (${field.name})`).join("\n") + : "- 当前数据来源没有明确的查询条件。"; + + return `# ${recipe.displayName}\n\n## 适合完成的任务\n\n${goal}\n\n## 数据来源\n\n这个技能会从用户已授权访问的页面数据来源读取数据。\n\n## 查询条件\n\n${queries}\n\n## 返回字段\n\n${fields}\n\n## 使用限制\n\n- 只用于用户有权访问的数据。\n- 不用于绕过登录、验证码、风控或访问限制。\n- 运行前应先用 recipe.json 测试数据预览是否正常。\n`; +} + +function buildExamplesMarkdown(recipe: DataRecipe, goal: string): string { + const firstUseCase = recipe.useCases[0] ?? goal; + + return `# 示例\n\n## 示例问题\n\n- 请根据「${recipe.displayName}」查看最新数据。\n- 请基于这些数据完成:${firstUseCase}\n\n## 示例输出\n\n系统会先测试运行 recipe.json,确认能读取数据,再基于返回字段进行整理和分析。\n`; +} + +function buildReadmeMarkdown(recipe: DataRecipe, goal: string): string { + return `# ${recipe.displayName}\n\n${goal}\n\n## 包含文件\n\n- SKILL.md: 给 AI Agent 使用的技能说明。\n- recipe.json: 可测试运行的数据配方。\n- examples.md: 示例问题和示例输出。\n- README.md: 给人看的说明。\n\n## 验证方式\n\n用 recipe.json 进行测试运行,并确认可以读取预期数据。\n`; +} diff --git a/packages/skill-builder/tsconfig.json b/packages/skill-builder/tsconfig.json new file mode 100644 index 0000000..334c4e4 --- /dev/null +++ b/packages/skill-builder/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../recipe-core" }] +} diff --git a/packages/skill-exporter/package.json b/packages/skill-exporter/package.json new file mode 100644 index 0000000..789ae50 --- /dev/null +++ b/packages/skill-exporter/package.json @@ -0,0 +1,21 @@ +{ + "name": "@data-recipe/skill-exporter", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "typecheck": "tsc -b tsconfig.json" + }, + "dependencies": { + "@data-recipe/skill-builder": "workspace:*" + } +} diff --git a/packages/skill-exporter/src/index.ts b/packages/skill-exporter/src/index.ts new file mode 100644 index 0000000..0f02b00 --- /dev/null +++ b/packages/skill-exporter/src/index.ts @@ -0,0 +1,77 @@ +import type { DataSkillPackage } from "@data-recipe/skill-builder"; + +export interface DataSkillPackageValidation { + ok: boolean; + missingFiles: string[]; + errors: string[]; +} + +export interface ExportedDataSkillPackage { + fileName: string; + mimeType: "application/json"; + content: string; +} + +const REQUIRED_FILES = ["SKILL.md", "recipe.json", "examples.md", "README.md"] as const; + +export function validateDataSkillPackage(skillPackage: DataSkillPackage): DataSkillPackageValidation { + const existing = new Set(skillPackage.files.map((file) => file.path)); + const missingFiles = REQUIRED_FILES.filter((file) => !existing.has(file)); + const errors: string[] = []; + + if (!skillPackage.packageName.trim()) { + errors.push("packageName is required"); + } + + skillPackage.files.forEach((file) => { + if (!file.content.trim()) { + errors.push(`${file.path} is empty`); + } + }); + + return { + ok: missingFiles.length === 0 && errors.length === 0, + missingFiles, + errors + }; +} + +export function validateExportedDataSkillPackageJson(content: string): DataSkillPackageValidation { + try { + const parsed = JSON.parse(content) as DataSkillPackage; + + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.files)) { + return { + ok: false, + missingFiles: [...REQUIRED_FILES], + errors: ["exported package must contain a files array"] + }; + } + + return validateDataSkillPackage(parsed); + } catch { + return { + ok: false, + missingFiles: [...REQUIRED_FILES], + errors: ["exported package is not valid JSON"] + }; + } +} + +export function exportDataSkillPackageAsJson(skillPackage: DataSkillPackage): ExportedDataSkillPackage { + const safeName = normalizeFileName(skillPackage.packageName || "data-skill"); + return { + fileName: `${safeName}.data-skill.json`, + mimeType: "application/json", + content: `${JSON.stringify(skillPackage, null, 2)}\n` + }; +} + +function normalizeFileName(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return normalized || "data-skill"; +} diff --git a/packages/skill-exporter/tsconfig.json b/packages/skill-exporter/tsconfig.json new file mode 100644 index 0000000..ead34fc --- /dev/null +++ b/packages/skill-exporter/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../skill-builder" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..4bad535 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1054 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + tsup: + specifier: ^8.2.4 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.5.4 + version: 5.9.3 + + apps/extension: + dependencies: + '@data-recipe/detector': + specifier: workspace:* + version: link:../../packages/detector + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../../packages/recipe-core + '@data-recipe/recipe-runner': + specifier: workspace:* + version: link:../../packages/recipe-runner + '@data-recipe/skill-builder': + specifier: workspace:* + version: link:../../packages/skill-builder + '@data-recipe/skill-exporter': + specifier: workspace:* + version: link:../../packages/skill-exporter + devDependencies: + rimraf: + specifier: ^6.0.1 + version: 6.1.3 + + packages/detector: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/recipe-core: {} + + packages/recipe-runner: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/skill-builder: + dependencies: + '@data-recipe/recipe-core': + specifier: workspace:* + version: link:../recipe-core + + packages/skill-exporter: + dependencies: + '@data-recipe/skill-builder': + specifier: workspace:* + version: link:../skill-builder + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/chrome@0.0.268': + resolution: {integrity: sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/chrome@0.0.268': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + + '@types/estree@1.0.9': {} + + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + fsevents@2.3.3: + optional: true + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + joycon@3.1.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + lru-cache@11.5.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minipass@7.1.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + package-json-from-dist@1.0.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3baf9ff --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - "apps/*" + - "packages/*" + +allowBuilds: + esbuild: true + +onlyBuiltDependencies: + - esbuild diff --git a/scripts/validate-data-skill-package.mjs b/scripts/validate-data-skill-package.mjs new file mode 100644 index 0000000..9b49d50 --- /dev/null +++ b/scripts/validate-data-skill-package.mjs @@ -0,0 +1,42 @@ +import { readFile } from "node:fs/promises"; + +const requiredFiles = ["SKILL.md", "recipe.json", "examples.md", "README.md"]; +const filePath = process.argv[2]; + +if (!filePath) { + console.error("Usage: pnpm validate:skill-package "); + process.exit(1); +} + +let parsed; +try { + parsed = JSON.parse(await readFile(filePath, "utf8")); +} catch (error) { + console.error(`Invalid JSON package: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + +const files = Array.isArray(parsed.files) ? parsed.files : []; +const existing = new Set(files.map((file) => file?.path)); +const missing = requiredFiles.filter((file) => !existing.has(file)); +const empty = files + .filter((file) => typeof file?.content !== "string" || file.content.trim() === "") + .map((file) => file?.path ?? "(unknown)"); + +if (typeof parsed.packageName !== "string" || parsed.packageName.trim() === "") { + console.error("Invalid package: packageName is required."); + process.exit(1); +} + +if (missing.length > 0 || empty.length > 0) { + if (missing.length > 0) { + console.error(`Missing files: ${missing.join(", ")}`); + } + if (empty.length > 0) { + console.error(`Empty files: ${empty.join(", ")}`); + } + process.exit(1); +} + +console.log(`Valid Data Skill Package: ${parsed.packageName}`); +console.log(`Files: ${requiredFiles.join(", ")}`); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..35a0c58 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@data-recipe/recipe-core": [ + "packages/recipe-core/src/index.ts" + ], + "@data-recipe/detector": [ + "packages/detector/src/index.ts" + ], + "@data-recipe/recipe-runner": [ + "packages/recipe-runner/src/index.ts" + ] + } + } +}