clone-website
從零開始,僅憑 URL 即可 1:1 複製任何線上網站(佈局、排版、資源、滾動動畫、狀態交互),並透過得分基準測試證明逼真度,而非僅憑主觀斷言。
運行時依賴與啟動
所有線上頁面操作均以 ego-browser nodejs <<'EOF' ... EOF 的形式在 Bash 中執行,使用 ego lite 運行時預先載入的 page、browser 和 taskSpaces 外觀。
此運行時不可替代,因為該方法在三個方面依賴於它。像素級別的循環需要在相同運行時中對原始頁面和複製頁面執行相同的探針,否則差異數字將毫無意義。修正循環需要任務空間:代理在其獨立空間中進行探測,而使用者同時在其空間中瀏覽原始頁面和複製頁面,隨時接管任何空間進行視覺檢查,然後交還,雙方不會爭奪瀏覽器。此外,本技能中的所有探針腳本和注意事項均針對此運行時的量測行為進行了校準。這就是為什麼缺少運行時會導向安裝 ego lite 而非尋找替代方案。
- 如果此環境中已安裝
ego-browser技能,請在第 0 階段之前載入它,並遵守其所有正確性規則。 本技能是它的包裝器;若發生任何衝突,ego-browser 技能的規則在瀏覽器機制方面優先,而本技能的規則在複製工作流程方面優先。 - 如果僅存在此技能,它可以獨立運作。 合約是
ego-browserCLI(由 ego lite 應用程式提供)。在新的或未知環境中,請在第 0 階段之前使用command -v ego-browser檢查一次。 - 當 CLI 缺失時,請不要自行應變。 停止,閱讀
references/install.md,並引導使用者安裝 ego lite(透過scripts/install-ego-browser.sh下載,然後使用者在應用程式 GUI 中完成入職,這將註冊 CLI;等待使用者確認,使用command -v ego-browser驗證,然後繼續)。切勿回退到 Playwright、其他瀏覽器、WebFetch 或任何其他自動化路徑;來自不同運行時的探測結果無法比較,且本技能的環境注意事項將不再適用。 - 在第一次成功命令之後,假定運行時已在會話的其餘部分準備就緒;僅在實際命令報錯時才進行調查。
一句話總結方法:將線上網站視為你可以查詢的事實來源,而非你可以憑肉眼觀察的圖片。從正在執行的頁面中提取真實的 DOM、真實的 CSS 權杖、真實的關鍵影格、真實的字型和真實的配置數據,根據這些事實進行重建,然後對原始頁面和複製頁面執行相同的量測探針,並比對數字,直到它們收斂。
檔案地圖:複製貼上探針腳本位於 references/probes.md;基準測試協定位於 references/eval.md;ego lite 安裝指南位於 references/install.md(安裝程式供應於 scripts/install-ego-browser.sh);工具/功能設計範本位於 references/tool-design-template.md;規範工具實作位於 tools/(註冊於 tools/manifest.json);評分器位於 scripts/eval_score.py;螢幕截圖切片器位於 scripts/slice.py。
執行模型
- 整個工作使用一個任務空間。 每個 heredoc 頂部使用
taskSpaces.useOrCreate('reverse <site>');所有呼叫使用相同名稱。不要在探測之間呼叫complete;僅在最終接受後使用{ keep: false }完成。如果使用者接管了空間,這是一個硬性停止:報告狀態並等待,切勿自行takeOver。 - 按階段批次處理,而非按操作。 一個 heredoc 應完成整個探測(開啟、凍結影片、在每個深度量測、輸出)。僅在真正的邊界處分割呼叫:外殼工作(curl 下載、grep、Python)、使用者確認或對已儲存影像的視覺檢查。
- 工件輸出到檔案,摘要輸出到標準輸出。 每個探測將完整的 JSON/HTML 寫入
reference/,且console.log僅輸出計數和路徑。切勿將完整的 DOM、CSS 檔案或幾何表格傾倒到對話中。 - 每個探測腳本是自包含的。 每次在頂部指定任務空間並使用
browser.openOrReuseTab(url, { wait: true, timeout: 30000 });運行時狀態不會在 heredoc 之間保留。 - 在 heredoc 內部僅使用絕對路徑。 ego 的 nodejs 程序不會繼承外殼的 cwd;相對路徑的
fs.writeFileSync會導致 ENOENT 錯誤。在每個腳本頂部定義一個ROOT常數。
快速開始:偵察啟動
一次呼叫捕獲重建起點所需的事實。請調整 URL 和路徑;複合形狀是重點。
mkdir -p reference/{dom,css,js,screenshots} && ego-browser nodejs <<'EOF'
const fs = await import('node:fs')
const ROOT = '/abs/path/to/project' // ego 的 nodejs 不會繼承外殼的 cwd
await taskSpaces.useOrCreate('reverse example.com')
await browser.openOrReuseTab('https://example.com', { wait: true, timeout: 30000 })
// 首先凍結自動播放影片:它們會飽和渲染線程,導致後續評估超時。
await page.evaluate(() => {
const freeze = () => document.querySelectorAll('video').forEach(v => v.pause())
freeze(); setInterval(freeze, 400)
})
// 守護視口:w/h 為 0 會靜默產生空的螢幕截圖和無效的矩形數據。
const info = await page.info()
if (!info.w || !info.h) throw new Error('視口為 0,請在偵察前還原真實分頁:' + JSON.stringify(info))
const facts = await page.evaluate(() => ({
html: document.documentElement.outerHTML,
outline: Array.from(document.querySelectorAll('body section, body header, body footer, body main > div'))
.map(el => { const r = el.getBoundingClientRect(); return {
tag: el.tagName, cls: (el.className?.toString() || '').slice(0, 120),
y: Math.round(r.top + scrollY), h: Math.round(r.height), w: Math.round(r.width) } })
.filter(s => s.h > 40).sort((a, b) => a.y - b.y),
cssUrls: performance.getEntriesByType('resource').map(e => e.name).filter(u => /\.css(\?|$)/.test(u)),
fontFiles: performance.getEntriesByType('resource').map(e => e.name).filter(u => /\.(woff2?|ttf|otf)(\?|$)/.test(u)),
jsChunks: performance.getEntriesByType('resource').map(e => e.name).filter(u => /\.js(\?|$)/.test(u)),
}))
fs.writeFileSync(ROOT + '/reference/dom/full-page.html', facts.html)
fs.writeFileSync(ROOT + '/reference/dom/outline.json', JSON.stringify(facts.outline, null, 2))
fs.writeFileSync(ROOT + '/reference/urls.json', JSON.stringify({ css: facts.cssUrls, fonts: facts.fontFiles, js: facts.jsChunks }, null, 2))
await page.evaluate(() => scrollTo(0, 0))
await page.waitForTimeout(800)
await page.screenshot({ path: ROOT + '/reference/screenshots/original-full.png', fullPage: true })
console.log(JSON.stringify({ sections: facts.outline.length, cssFiles: facts.cssUrls.length,
fontFiles: facts.fontFiles.length, jsChunks: facts.jsChunks.length, htmlKB: Math.round(facts.html.length / 1024) }))
EOF
然後進行外殼工作:將每個 CSS/字型/JS URL 使用 curl 下載到 reference/,並使用 python3 scripts/slice.py reference/screenshots/original-full.png reference/dom/outline.json reference/screenshots/original/ 按區塊切片螢幕截圖。
工作流程
第 0 階段,設定
專案目錄包含 reference/{dom,css,js,screenshots}/ 用於存放偵察工件(永不交付,從任何倉庫推送中排除);開發伺服器在背景的固定埠上運行,以便使用者可以持續審查。當 public/ 已包含下載的資源時,手動搭建 Next.js;create-next-app 會覆蓋該目錄。
在提供服務之前,lsof -iTCP:<port> -sTCP:LISTEN(先前複製工作留下的伺服器會令人信服地回應您的埠),以及在對複製頁面進行任何探測之前,斷言 document.title 匹配;一個合理的 curl 回應並不能證明瀏覽器分頁顯示了您的頁面。
第 1 階段,靜態捕獲
執行偵察啟動,然後填補空白(完整腳本在 references/probes.md 中):
- 設計權杖:在已下載的 CSS 中 grep
--brand-*、--font-size-*、--color-*、間距比例。從權杖中讀取大小和顏色,切勿從螢幕截圖中估算。您預期但找不到的權杖通常位於另一個 CSS 檔案中;在得出它不存在的結論之前,請 grep 所有檔案。 - 字型:
@font-face通常由 JS 在執行時注入,因此 grep 已儲存的 HTML 並不能證明什麼。在線上頁面上結合三個來源:performance.getEntriesByType('resource')篩選字型擴展名、document.fonts獲取 font-family/weight/status、以及遍歷document.styleSheets獲取CSSFontFaceRule以將 font-family 映射到 weight 和 src。使用curl --globoff下載(帶括號的檔名會破壞 curl 的 glob 功能)。自行託管在public/fonts/。 - 資源:枚舉
img[src]、srcset、video src、background-image URL、內聯 SVG。將/_next/image?url=...解碼回原始路徑。在public/下鏡像原始路徑。逐字提取內聯 SVG,不要重新繪製。 - 模擬/產品 UI 內容:儀表板、編輯器和卡片面板的內容來自已編譯 JS 區塊中的配置物件。在已下載的區塊中 grep 可見字串;命中字串旁邊的配置物件持有完整的資料集(所有輪播變體、所有行),遠比可能遺漏延遲載入狀態的螢幕截圖更可靠。
參考螢幕截圖遵循以下正確性規則:一個 scroll 0 的 fullPage 捕獲,使用 scripts/slice.py 按區塊切片,首先凍結影片,首先重置狀態欄。
第 2 階段,動作與互動逆向
從最便宜的方式開始;一旦效果被解釋清楚就停止:
- CSS 靜態分析(免費,已下載):grep
animation-timeline|view-timeline|scroll-timeline|@keyframes。如果命中animation-timeline: view(),表示是原生 CSS 滾動驅動動畫:逐字複製關鍵影格和範圍,無需 JS。 - 執行時庫存:
document.getAnimations({ subtree: true }),這是事實來源。每個動畫包含:effect.getKeyframes()、timeline.constructor.name(ViewTimeline表示滾動驅動,DocumentTimeline表示時間驅動)、rangeStart/rangeEnd、目標元素。每個元素的參數如--parallax-from: 6%位於內聯樣式上。 - MutationObserver 加上真實滾輪滾動:用於 JS 觸發的效果:
attributeFilter: ['class','style','src'],使用page.mouse.wheel滾動整個頁面,記錄突變。整個滾動過程中零類突變證明效果是純 CSS,這將極大簡化重建。 - 多深度狀態取樣:用於固定/黏性狀態機(浮動輸入欄、折疊標頭):在多個滾動深度上(雙向)快照元素整個子樹(每個子元素的 rect、opacity、radius、background、shadow),然後比對不同狀態之間的快照。僅檢查位置會遺漏形狀變化;狀態轉換才是交互本身。
第 3 階段,重建
首先根據網站類型選擇重建路徑。 靜態匯出網站(Webflow、Framer:data-wf-site 屬性、website-files.com/framerusercontent.com CDN)以原始 HTML 鏡像的形式進行複製,逼真度最高,而非元件重建:使用預水合的伺服器 HTML 作為骨架(捕獲的即時 DOM 已經水合——輪播克隆已插入、SplitText 字元已拆分——當腳本重新執行時會雙重初始化),將每個 CDN URL 重寫為自行託管的副本,移除分析/同意腳本,保留功能運作時(網站 JS、GSAP、輪播庫)並自行託管。該路徑有四條規則:
- 資源可能跨越多個 CDN 資料夾(共享媒體庫);按 basename 重寫任何
cdn-host/<folder>/<file>,而非按網站的主資料夾前綴。 - 從每個重寫的資源標籤中移除
integrity/crossorigin:SRI 雜湊是針對原始位元組計算的,瀏覽器會靜默拒絕修改後的檔案——症狀是頁面無樣式(文件高度暴增、document.fonts為空)且沒有探測可見的錯誤。 - 按關鍵字阻止列表移除追蹤腳本會殺死那些僅僅提及具有優雅降級功能的分析 API 的功能腳本(一個控制下載按鈕作業系統偵測文字切換的 head 腳本引用了 PostHog);審查已移除的腳本列表,並將功能腳本加入白名單。
- 非確定性實驗腳本(
Math.random()流量拆分、重新導向測試)即使具有功能也應保持移除;它們會使基準測試運行無法重現。
對於 JS 框架網站(Next.js、Remix——水合不匹配使得鏡像不可行),以元件方式重建:
- 每個頂級區塊對應一個元件,以區塊的業務內容命名。
- 來自第 1 階段的權杖按原樣放入
globals.css;字型以真實權重映射自行託管。 - 鏡像原始的 DOM 掛載策略,而不僅僅是可見內容:原始頁面保留所有輪播影格、旋轉變體和頭像牆(隱藏),而條件渲染的克隆會顯示相同的像素但元素數量更少,然後在基準測試中每個區塊的媒體計數失敗。根據原始 DOM 比對每個區塊的元素計數,而不是根據其螢幕截圖。
- 複製原始的容器層次結構,而不僅僅是可視效果。
sticky的作用範圍受限於其父元素:一個必須持續到頁尾的導航列需要一個跨越整個頁面的父元素;一個窗簾揭開面板需要一個效果起始處的父元素,否則它會從第一個畫面就開始固定。當區塊在重構後出現問題時,首先懷疑是 sticky 或堆疊上下文範圍的變更。 - 視口固定元素(浮動聊天欄)在結構良好的原始頁面中是 body 層級的固定容器:使用 React portal,切勿在某個區塊內部使用
absolute。 - 改變文件高度的折疊標頭會與 Chrome 滾動錨定機制衝突並導致抖動。穩健的架構:在流中放置一個恆定高度的佔位符(文件高度永不改變),內部標頭在完全滾出後切換為
fixed並加上滑入動畫。如果必須動畫高度,請添加overflow-anchor: none以及在折疊和展開閾值之間添加遲滯。 - 裝飾性鋸齒邊緣(扇貝形/郵票形邊框):實心邊 SVG 朝向間隙翻轉,與相鄰元素重疊 1px。
...
(由於原始內容被截斷,翻譯也在此截止。)