Paste a Notion URL to automatically extract the page's requirement content and organize it into structured Markdown; by default it then chains into /design to create an implementation plan (one command completes Notion fetching → structured organization → plans/active/.md output), butcreating a plan is just one use case—for scenarios like fixing bugs based on Notion requirements, reading specs to compare against Figma, or simply wanting the page content, where you "only read and don't create a plan," you should likewise use this skill to fetch; just stop after Step 4 completes and hand the content back to the current conversation, without triggering Step 5's /design。
Use
playwright-cli(not Playwright MCP): the snapshot is saved to.playwright-cli/*.ymland read on demand, avoiding automatically injecting ~58KB into context on every operation.
Step 0: Login persistence (first-time setup)
Use --profile to persist the Notion session, avoiding re-logging in every time. Profile path:~/.playwright-cli/notion-profile
First login: If a login is detected as required (Step 2c), use AskUserQuestion to provide options:
Notion requires login to access this page.
Options:
- Open the login page for me — automatically runs a headed browser and opens the Notion login page for manual login
- Manually paste content — skips browser login and directly paste the page's requirement content
If the user chooses "Open for me":
# 先關閉現有 session(避免衝突)
playwright-cli -s=notion close
# 以 headed 模式開啟登入頁面(notion.com 為新主網域,notion.so 會轉址至此)
playwright-cli -s=notion open "https://www.notion.com/login" --profile ~/.playwright-cli/notion-profile --headed
After opening, prompt the user: "The browser has been opened, please log in to Notion. Let me know when you're done." After the user confirms login, use the same session to navigate to the target page and continue fetching. The login state is persisted, so no repeated login is needed afterward.
Domain migration note: Notion has migrated its primary domain from
notion.sotonotion.com,notion.sowill 301 redirect tonotion.com. The login cookie is bound to the domain actually landed on, so always log in vianotion.comto avoid the session of an oldnotion.soprofile becoming invalid after the redirect.
Step 1: Parse the URL
Get the Notion URL from the argument provided by the user. The following formats are supported:
| Format | Example |
|---|---|
| notion.com page (new primary domain) | https://www.notion.com/workspace/Page-Title-abc123 |
app.notion.com page (workspace app subdomain, including the /p/ path) |
https://app.notion.com/p/<workspace>/Page-Title-abc123 |
| notion.so page (old domain, redirects to notion.com) | https://www.notion.so/workspace/Page-Title-abc123 |
| notion.site public page | https://workspace.notion.site/Page-Title-abc123 |
| Database (either com or so) | https://www.notion.com/workspace/abc123?v=def456 |
| Short URL | https://notion.com/abc123、https://notion.so/abc123 |
Extract from the URL pageId(the final 32-character hex, possibly separated by - or contiguous)—this logic is independent of the domain, subdomain, and path prefix, and is shared bynotion.com / app.notion.com / notion.so / notion.site ; theapp.notion.com of /p/<workspace>/ path prefix does not affect the extraction result.
Valid domain allowlist (dot-boundary matching): host equal to notion.com / notion.so / notion.site,or ends with .notion.com / .notion.so / .notion.site only then is it considered valid (covering subdomains such as www./app./<workspace>. , while blocking evilnotion.com domains impersonating with the same tail).
If $ARGUMENTS is empty, use AskUserQuestion to ask the user to provide the Notion URL.
If the URL's host is not in the above whitelist, prompt the user to confirm.
Step 2: Playwright CLI scraping
Do not use WebFetch / Playwright MCP (see global rule webfetch-blocklist.md: Notion is 100% client-side rendering, and MCP injects ~58KB snapshot per operation).
All playwright-cli commands use session -s=notion and persistent profile --profile ~/.playwright-cli/notion-profile。
Quoting rules:
evalcommand outer layer uses single-quote, inner JS uses double-quote, to avoid\"escape causing Playwright serialization errors.
Instability stop-loss (measured 2026-07-10): headless Notion session repeatedly self-disconnects (browser restarts into in-memory, execution context destroyed, click ref invalid causing misnavigation to other sidebar pages). Rules:On the first snapshot, retrieve all properties and comments at once(the accessibility tree contains
innerTextfields and comments that cannot be obtained, read them all in one Read); for subsequent interaction expansion (toggle, "Show N replies") failures,retry limit 2 times, if still failing, annotate the gap in the output (e.g., "there are N collapsed replies under this comment not captured") and hand it to downstream HITL for supplementary reading; it is not worth retrying indefinitely—gap annotation itself is a qualified output.
2a. Navigate to the page
playwright-cli -s=notion open "<notion_url>" --profile ~/.playwright-cli/notion-profile
2b. Wait for content to load
Use eval polling to wait for the Notion content selector to appear:
playwright-cli -s=notion eval '() => new Promise((resolve, reject) => { const selectors = [".notion-page-content", ".notion-frame", "[data-block-id]", ".layout-content"]; const check = () => { for (const s of selectors) { if (document.querySelector(s)) return resolve(s); } setTimeout(check, 500); }; check(); setTimeout(() => reject("timeout: no Notion content found"), 15000); })'
If a non-zero exit code is returned or stderr contains error/reject messages, treat it as a timeout and proceed to Step 2c to check whether login is required.
2c. If login is required
Obtain a snapshot to check the page state (write to the runtime working directory's .playwright-cli/):
playwright-cli -s=notion snapshot
ls -t .playwright-cli/*.yml | head -1 # 找到最新 snapshot 檔案(於專案根目錄執行)
Use the Read tool to read that yml file. Judgment condition: the snapshot does not contain any data-block-id related content, and contains email, "Sign in", "Log in", "Continue with", etc. → treat as a login page, use AskUserQuestion to provide options (same as Step 0). If a persistent profile already exists and the session is valid, continue directly.
2d. Capture page content
First expand all Toggle blocks (Notion toggles are collapsed by default, child content is not in the DOM):
playwright-cli -s=notion eval '() => { const toggles = document.querySelectorAll(".notion-toggle-block > div[role=button]"); toggles.forEach(t => { if (t.getAttribute("aria-expanded") !== "true") t.click(); }); return toggles.length; }'
If any toggle was expanded,sleep 1 wait for child content to load. Use eval to extract the page text content (primary extraction method, sufficient to obtain the complete text):
playwright-cli -s=notion eval '() => document.querySelector(".notion-page-content")?.innerText || "NO CONTENT"'
If more structured extraction is needed (title + content separation):
playwright-cli -s=notion eval '() => { const title = document.querySelector(".notion-page-block, .notion-collection_view-block h1, [data-root=true] h1")?.textContent?.trim() || ""; const content = document.querySelector(".notion-page-content")?.innerText || ""; return JSON.stringify({ title, content }); }'
If the user's requirements mention property fields such as Status, assignee, due date, or the page is expected to contain comment discussions, then after completing 2d additionally execute Step 2f (
eval innerTextcannot obtain properties and comments, they exist only in the accessibility tree).
2d-verify. Capture completeness check
Before continuing, verify capture completeness against the snapshot YAML:
- Obtain the number of lines in the snapshot containing
data-block-idasblock_count - Calculate the number of lines in the
innerTextreturned content (line_count) - If
line_count < block_count * 0.5, treat as incomplete capture,force trigger Step 2e scrolling - Otherwise record the ratio for use in Step 4, and continue execution
2e. Handle long pages
If the page content requires scrolling to load completely:
playwright-cli -s=notion eval '() => new Promise(resolve => { let lastHeight = document.body.scrollHeight; const distance = 500; const timer = setInterval(() => { window.scrollBy(0, distance); const newHeight = document.body.scrollHeight; if (newHeight === lastHeight) { clearInterval(timer); resolve(true); } lastHeight = newHeight; }, 300); setTimeout(() => { clearInterval(timer); resolve(true); }, 30000); })'
Use
scrollHeightstability check (stop when the height remains unchanged for two consecutive times), rather than cumulative distance comparison, to avoid lazy-load continuously expanding the page and causing infinite scrolling.
After scrolling is complete, scroll back to the top and re-capture the content:
playwright-cli -s=notion eval '() => window.scrollTo(0, 0)'
2f. Capture properties / comments (optional)
If the requirements include page properties (such as Status, Assignee, Due Date) or comment discussions, extract them from the snapshot YAML:
playwright-cli -s=notion snapshot
ls -t .playwright-cli/*.yml | head -1
Use the Read tool to read, and search in the YAML for:
- Page properties:
table "Page properties"block, extract the key-value of each property - Comments:
CommentsorDiscussionblock, extract the comment content and author
2g. Close session
playwright-cli -s=notion close
Step 3: Organize into structured Markdown
Clean up residual Notion UI text, preserve the semantic structure (heading levels, lists, code blocks, tables), and output:
# [頁面標題]
> 來源:[Notion URL]
> 擷取時間:[YYYY-MM-DD HH:MM]
---
[頁面內容,保留原始 Markdown 結構]
Convert special content according to convention: Table/Database → Markdown table; Toggle → after expanding, present with <details> or indentation; Callout → blockquote; code blocks preserve ``` and language tags; embeds are marked as [Embedded: URL]; images use  or [Image: description]。
Step 4: Content quality confirmation
Verify the organized Markdown item by item; if any FAIL, use AskUserQuestion to ask the user:
| # | Verification item | Result |
|---|---|---|
| 1 | Has a title (non-empty, not "Untitled") | PASS / FAIL |
| 2 | Has paragraph text (>50 characters) | PASS / FAIL |
| 3 | (If the URL contains ?v= database parameter) has table data |
PASS / FAIL / N/A |
| 4 | Content length matches expectations (the line_count / block_count ratio from Step 2d-verify ≥ 0.5) |
PASS / FAIL |
All PASS (or N/A) → proceed to Step 5; any FAIL → display the failed items and ask whether login is needed or whether to switch to manually pasting the content.
Step 5: Output the Notion content, and decide whether to trigger /design depending on the purpose
Read-only, no plan created(For scenarios such as fixing bugs based on Notion requirements, comparing with Figma, simply wanting page content, etc., or when the user brings --read-only):
Output the structured Markdown organized in Step 3 in full into the conversation and end; return the content for use by the current task,do not trigger /design。
Plan to be created(Default purpose, or the user explicitly requests creating an implementation plan):
Output the structured Markdown organized in Step 3 in full into the conversation, for subsequent /design reading, then trigger:
Skill(skill="design", args="[SOURCE: /notion-plan] 根據上方 Notion 頁面的需求內容建立實作計畫")
[SOURCE: /notion-plan]is a traceability marker, only indicating the source of the requirement;/designdoes not perform any skip handling (unlike/update → /prin[PIPELINE]which has different semantics), but instead reads the Notion content from the conversation context, with zero modification/design。After triggering,
/design's Step 2a will first dispatch a lightweight triage subagent (haiku) to determine complexity before deciding whether to take the fast path or the full process—test reports/single bug tickets usually fall on the fast path, and it will not unfold the full ceremony for a single P2 ticket.
Usage
/notion-plan https://www.notion.com/workspace/Page-Title-abc123
/notion-plan https://www.notion.so/workspace/Page-Title-abc123 # 舊網域,自動轉址至 notion.com
/notion-plan https://workspace.notion.site/public-page-abc123
/notion-plan https://app.notion.com/p/workspace/Page-Title-abc123 --read-only # 只讀內容,不建 plan
Limitations
- First use requires manual login to Notion in headed mode (the session is then persisted)
- The filtering/sorting/grouping of a Database view is based on the page's current state
- Very long pages (100+ blocks) may require multiple scrolls, and capture takes longer