Skills
9348 foundAgent Skills are multi-file prompts that give AI agents specialized capabilities. They include instructions, configurations, and supporting files that can be used with Claude, Cursor, Windsurf, and other AI coding assistants.
Infer a player's underlying values and motivational priorities from behavior, then translate those into design implications. Use when designing personalizati...
--- name: game-design-player-values-mapper description: Infer a player's underlying values and motivational priorities from behavior, then translate those into design implications. Use when designing personalization, segmentation, dynamic guidance, live-ops targeting, adaptive missions, re-engagement strategies, or feature prioritization; when behavior suggests that what players actually care about differs from what the design assumes; or when a team needs a behavior-first player profile rather than a demographic or archetype-only model. --- # Game Design Player Values Mapper Map observed player behavior to likely underlying value priorities, then use that map to infer what kinds of goals, rewards, content, or framing are most likely to resonate. Use this skill when the team needs to understand not just what players do, but what those choices imply about what they care about. ## Core principle Behavior is not random. It is preference made visible. Players reveal their values through repetition, avoidance, investment, and attention. The goal is not to assign a rigid personality label, but to infer the motivational structure most likely driving current behavior and use that to improve design alignment. ## What to produce Generate: 1. **Observed behavior summary** - what the player consistently does, ignores, and invests in 2. **Value map** - likely dominant, secondary, and weak values 3. **Confidence notes** - how strong or ambiguous each inference is 4. **Tensions or contradictions** - where behavior suggests mixed motives or blocked values 5. **Design implications** - what systems, content, messaging, goals, or monetization surfaces are likely aligned or misaligned 6. **Segment hypothesis** - what kind of player pattern this most resembles in practical design terms 7. **Recommendations** - what to emphasize, reframe, personalize, or stop pushing ## Value framework Map behavior to these value dimensions: - **Efficiency / Optimization** - **Progression / Growth** - **Aesthetics / Expression** - **Collection / Completion** - **Social Recognition / Status** - **Experimentation / Discovery** - **Narrative / Meaning** You may add a clearly justified extra value if the case demands it, but do not bloat the framework casually. ## Process ### 1. Gather behavior signals List concrete observed behaviors. Possible sources: - build patterns - resource spending - session frequency and duration - event participation - feature engagement - purchase behavior - social behavior - what the player returns to repeatedly - what the player ignores despite obvious rewards Write: - **Repeated behaviors** - **Avoided behaviors** - **Investment patterns** ### 2. Map behaviors to likely value signals Translate behavior into value hypotheses. Examples: - min-maxing production chains -> Efficiency / Optimization - constant upgrading and rushing unlocks -> Progression / Growth - decorating, styling, curating loadouts -> Aesthetics / Expression - chasing every item or badge -> Collection / Completion - caring about ranks, cosmetics, visibility -> Social Recognition / Status - trying odd builds or niche tools -> Experimentation / Discovery - following lore, theme, faction identity, story arcs -> Narrative / Meaning Important: many behaviors can map to more than one value. Do not overclaim certainty. ### 3. Weight the value profile Do not force fake precision. The goal is a useful profile, not pseudo-scientific certainty. Assign rough weight levels such as: - High - Medium - Low Or if needed: - Dominant - Secondary - Weak - Absent Also note confidence: - high confidence - medium confidence - low confidence Use this format: | Value | Weight | Confidence | Evidence | |---|---|---|---| | ... | ... | ... | ... | ### 4. Detect tensions and blocked values Look for contradictions. Examples: - optimization-driven player engaging with decoration only because progression forces it - status-seeking player avoiding competition because the failure cost feels humiliating - progression-oriented player not spending because they distrust the offer structure - discovery-oriented player repeating safe loops because experimentation is too punished Ask: - is this a real mixed-value profile? - or is one value being blocked by system design? ### 5. Infer likely design alignment Answer: - what currently motivates this player most? - what kinds of content or objectives will likely land well? - what incentives are probably weak for this player? - where is the game asking for a value the player does not strongly hold? - what part of the experience is likely causing silent disengagement? - what messaging, reward framing, or mission framing is most likely to resonate? ### 6. Form a practical segment hypothesis Translate the value map into a practical design-facing player pattern. Examples: - efficiency-first optimizer - completionist collector with moderate status drive - expressive builder with weak progression urgency - growth-focused grinder with low experimentation tolerance - discovery-oriented tinkerer blocked by punishment This is not meant to replace deeper persona work. It is a compact operational summary that helps teams act. ### 7. Recommend design actions Translate the value map into actions such as: - personalize mission framing - surface a different kind of goal - target events/offers more intelligently - reduce pressure toward misaligned systems - give better tools to the dominant value type - redesign progression framing for the current segment - change how rewards are explained, not just what rewards are given - stop over-serving a secondary value while neglecting the dominant one ## Response structure ### Observed Behavior Summary - ... ### Player Value Map | Value | Weight | Confidence | Evidence | |---|---|---|---| | ... | ... | ... | ... | ### Dominant Values - ... ### Secondary Values - ... ### Tensions / Contradictions - ... ### Segment Hypothesis - ... ### Design Implications - ... ### Recommendations 1. ... 2. ... 3. ... ## Fast mode Use this quick pass when speed matters: - what does the player repeatedly choose? - what do they ignore? - what does that imply they value? - what is the strongest mismatch between the player's values and the game's current asks? - what practical segment hypothesis best describes this player? - what should the design emphasize or stop emphasizing for this player? ## Working principle A player rarely says their values directly. They leak them constantly through what they pursue, what they skip, and what they are willing to suffer for.
Audit a game, feature flow, economy path, onboarding journey, progression chain, or live-ops loop for friction quality and friction accumulation. Use when di...
--- name: game-design-friction-journey-audit description: Audit a game, feature flow, economy path, onboarding journey, progression chain, or live-ops loop for friction quality and friction accumulation. Use when diagnosing where players stall, disengage, churn, or feel overloaded; when distinguishing productive challenge from harmful friction; or when evaluating whether constraints, waiting, confusion, resource pressure, or multi-step dependencies are creating strategy, tension, frustration, or deadlock. --- # Game Design Friction Journey Audit Audit a design by mapping where friction appears across a player journey, what kind of friction it is, how it accumulates, and where useful challenge mutates into harmful drag. Use this skill when a feature feels sticky in the wrong way, when progression seems to slow down for reasons players cannot articulate clearly, or when you need to separate meaningful challenge from accidental obstruction. ## Core principle Not all friction is bad. Some friction creates commitment, decision-making, anticipation, and mastery. Other friction creates confusion, paralysis, resentment, or churn. The job is not to remove all resistance. The job is to identify which resistance is doing design work and which is merely getting in the player's way. ## What to produce Generate: 1. **Audit target** - what journey, loop, or feature is being reviewed 2. **Journey breakdown** - the major steps in player progression through the target flow 3. **Friction map** - where friction appears, what kind it is, and what causes it 4. **Accumulation analysis** - where multiple frictions stack into exhaustion or deadlock 5. **Diagnosis** - where the design shifts from meaningful challenge to harmful blockage 6. **Recommendations** - what to preserve, reduce, surface, reorder, or remove ## Process ### 1. Define the journey being audited Clarify: - what system or flow is under review - what kind of player it applies to - what stage of play it belongs to: FTUE, early game, mid-game, elder game, event loop, monetization path, social loop, etc. - what desired player behavior the flow is supposed to support Write: - **Audit target** - **Expected player goal** - **Player context** ### 2. Break the journey into steps Map the journey as a sequence of player-facing steps. For each step, identify: - player action - player decision - requirement or dependency - feedback or reward - what unlocks the next step Keep steps coarse enough to be readable but concrete enough to locate friction. ### 3. Identify friction at each step For each step, ask: - what slows progress? - what blocks progress? - what creates uncertainty? - what consumes time, attention, or resources? - what forces tradeoffs or commitment? Possible friction sources: - resource scarcity - dependency chains - waiting and timers - unclear affordances or goals - UI or information opacity - cognitive overload - skill challenge - social coordination burden - random variance - harsh penalty or recovery cost - monetization pressure ### 4. Classify the friction Classify each friction point as one of these: #### Productive friction Supports: - decision-making - planning - anticipation - mastery - commitment - strategic tradeoff - emotional tension that feels fair and legible #### Harmful friction Produces: - confusion - dead time without meaning - arbitrary blocking - unreadable requirements - overloaded task chains - repeated admin work - punishment without learning - progress paralysis #### Mixed friction Useful in principle, but currently too strong, too opaque, too stacked, or too poorly timed. Do not treat this as binary if it is not. Many systems are good ideas implemented at the wrong intensity. ### 5. Assess intensity and visibility For each friction point, rate: - **Intensity** - low / medium / high - **Visibility** - obvious / partially hidden / opaque - **Fairness feel** - fair / borderline / unfair-feeling A friction can be mild but still dangerous if it is hidden. It can also be intense but acceptable if the player clearly understands it and sees why it exists. ### 6. Analyze friction accumulation Look for stack effects. Ask: - where do several medium frictions compound into a high-friction moment? - where are players forced to satisfy too many constraints at once? - where does the flow ask for too much memory, too much waiting, or too many parallel tasks? - where do repeated harmful frictions appear without enough reward, clarity, or release? Common accumulation patterns: - multiple resources plus timer plus low clarity - complex chain plus weak feedback plus low inventory space - repeated losses plus long recovery plus weak learning signal - social obligation plus schedule pressure plus poor coordination tools ### 7. Find the breakpoints Identify: - where challenge turns into drag - where strategy turns into opacity - where anticipation turns into dead time - where difficulty turns into helplessness - where a healthy loop turns into churn risk These are the key design breakpoints. ### 8. Diagnose the role of friction in the design Answer: - which friction points are core to the fantasy or mastery arc? - which friction points only exist because of weak clarity, weak UX, poor pacing, or over-constrained economy? - what friction is essential and should be protected? - what friction is currently doing accidental damage? ### 9. Recommend design changes For each major friction issue, specify: - **Issue** - **Why it hurts** - **Keep / reduce / remove / surface / reorder / soften** - **Expected effect** Typical interventions: - surface hidden requirements - reduce simultaneous constraints - improve feedback and goal clarity - shorten dead-time without removing commitment - preserve meaningful tradeoffs while removing admin burden - stagger dependencies instead of stacking them all at once ## Response structure ### Audit Target - ... ### Journey Breakdown 1. ... 2. ... 3. ... ### Friction Map | Step | Friction Point | Type | Cause | Intensity | Visibility | Fairness Feel | |---|---|---|---|---|---|---| | ... | ... | ... | ... | ... | ... | ... | ### Accumulation Analysis - ... ### Breakpoints - ... ### Diagnosis - ... ### Recommendations 1. ... 2. ... 3. ... ## Fast mode Use this quick pass when speed matters: - where does the player slow down or stop? - is the friction creating strategy or confusion? - is it fair and legible? - what other frictions are stacking nearby? - what should be preserved, softened, surfaced, or removed? ## Working principle Good friction gives the player something meaningful to push against. Bad friction makes the player wonder why they are pushing at all.
Ajinomoto is a Japanese biotech firm that commercialized umami and leads global production of MSG and amino acid products for food and pharma.
---
summary: Ajinomoto is a Japanese multinational food and biotechnology company that discovered and commercialized umami — the fifth taste — and remains the world's largest producer of monosodium glutamate (MSG) and amino acid products.
read_when:
- Studying the global food science and flavor industry
- Analyzing Ajinomoto expansion from MSG to biotechnology and pharma
- Researching umami taste science and its impact on global cuisine
- Understanding Japanese corporate innovation in food technology
---
# Ajinomoto
## Overview
Ajinomoto is a Japanese multinational food and biotechnology company that discovered and commercialized umami — the fifth taste — and remains the world's largest producer of monosodium glutamate (MSG) and amino acid products.
## Historical Timeline
- 1909: Kikunae Ikeda discovers umami taste and patents MSG production
- 1925: Ajinomoto Co formally established in Tokyo
- 1956: Discovers industrial fermentation process for amino acid production
- 1980s: Expands into pharmaceuticals and biotechnology
- 2000: Launches 'Eat Well, Live Well' brand transformation
- 2024: Announces major investment in cultivated meat and alternative protein
## Business Model
Three segments: Seasonings and Foods (45%), AminoScience (35% — pharma, animal nutrition, sweeteners), and Frozen Foods (20%). Revenue from B2C food products (Ajinomoto brand MSG, Cook Do sauce mixes) and B2B amino acid ingredients for pharmaceutical and animal feed industries.
## Moat Analysis
Proprietary fermentation technology for amino acid production — over 100 years of process optimization. Umami discovery gives scientific credibility and brand authority in flavor science. Vertical integration from raw materials to finished food products.
## Key Data
- revenue: ~¥1.3 trillion (~$9B) (2023)
- msg_production: ~30% of global supply
- employees: ~37,000
- countries: ~80+
- r_and_d: ~¥40B/year
## Interesting Facts
- Professor Kikunae Ikeda discovered umami by tasting dashi broth and identifying glutamate as the source — he then crystallized it from kombu seaweed and patented the extraction process.
- Despite global MSG stigma in Western markets, Ajinomoto's MSG production has never stopped growing — it is now used in 90%+ of processed foods worldwide.
Systematically assess web application session management for security vulnerabilities. Use when testing session token generation quality, cookie security con...
---
name: session-management-security-assessment
description: |
Systematically assess web application session management for security vulnerabilities. Use when testing session token generation quality, cookie security configuration, session fixation susceptibility, cross-site request forgery (CSRF) exposure, or session token handling across a session's full lifecycle. Covers the complete taxonomy of generation weaknesses (meaningful tokens with user data embedded, predictable tokens from concealed sequences or time-dependent algorithms or weak pseudorandom number generators, encrypted tokens vulnerable to ECB block rearrangement or CBC bit-flipping) and handling weaknesses (cleartext transmission, token disclosure in server logs or URLs, vulnerable token-to-session mapping, ineffective logout and expiration, client-side hijacking exposure, overly liberal cookie domain or path scope). Use when someone says 'test our session tokens', 'analyze cookie security', 'check for session fixation', 'verify CSRF protection', 'assess token predictability', 'evaluate our session management', 'can session tokens be guessed', 'review logout implementation', 'check cookie flags', or 'audit session security'. Produces a structured vulnerability report with per-weakness findings and remediation guidance. Framed for authorized security testing, defensive security assessment, and educational contexts.
model: sonnet
context: 1M
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Source code, HTTP traffic captures, server configuration, security scan reports"
- type: none
description: "Can also operate from live application access in an authorized test environment"
tools-required: [Read, TodoWrite]
tools-optional: [Grep, Bash, WebFetch]
environment: "Authorized penetration test or security assessment environment; codebase or HTTP proxy history preferred"
---
# Session Management Security Assessment
## When to Use
Use this skill when you are conducting an **authorized security assessment** of a web application's session management mechanism. Applicable contexts:
- **Penetration testing** — systematically finding exploitable session weaknesses before an attacker does
- **Security code review** — evaluating session token generation logic, cookie configuration, and lifecycle management in source code
- **Security architecture review** — assessing whether the session design meets security requirements before deployment
- **Vulnerability verification** — confirming or ruling out reported session issues with structured test evidence
This skill covers two orthogonal vulnerability classes: weaknesses in how tokens are **generated** (can an attacker predict or derive tokens issued to other users?) and weaknesses in how tokens are **handled** after generation (can an attacker obtain or misuse tokens through network capture, log access, fixation, or client-side attacks?).
**Preconditions:** You have at least one of:
- Source code including session token generation logic
- HTTP proxy history from an authenticated walkthrough of the application
- Live authorized access to a test instance of the application
**Agent:** This assessment requires authorized access. Confirm scope authorization before beginning any active testing steps. Do not perform active token capture or manipulation against systems you are not authorized to test.
## Context & Input Gathering
### Input Sufficiency Check
```
User prompt → Extract: application under test, scope authorization, available artifacts
↓
Environment → Scan for: source files, HTTP logs, config files, cookie headers
↓
Gap analysis → Do I know WHAT to test and DO I have authorized access?
↓
Missing critical info? ──YES──→ ASK (one question at a time)
│
NO
↓
Confirm authorization → PROCEED with systematic assessment
```
### Required Context (must have — ask if missing)
- **Authorization confirmation:** Is this assessment authorized? Who authorized it and for which systems?
→ Without this, do not proceed with active testing steps.
- **Application identity:** Which application or endpoint is being assessed?
→ Check prompt for: URL, application name, repository path, or system description.
- **Available artifacts:** What artifacts are available — source code, HTTP proxy history, live access?
→ This determines which assessment steps can be performed with full confidence vs inferred.
### Observable Context (gather from environment)
- **Session token location:** How is the session token transmitted? Cookie, URL parameter, hidden form field, custom header?
→ Grep for: `Set-Cookie`, `sessionId`, `jsessionid`, `PHPSESSID`, `ASP.NET_SessionId`, `token=` in URL patterns
→ WHY: The transmission mechanism determines which handling weakness tests apply (e.g., URL transmission exposes to log disclosure; cookies expose to scope and flag issues).
- **Token generation code:** Where and how are tokens generated?
→ Grep for: `Random`, `SecureRandom`, `uuid`, `session_start`, `generateToken`, `Math.random`, `rand()`
→ WHY: Generation code reveals whether the source of entropy is cryptographically secure.
- **Cookie attributes:** What flags are set on session cookies?
→ Grep for: `Secure`, `HttpOnly`, `SameSite`, `domain=`, `path=` in `Set-Cookie` headers or config
→ WHY: Missing `Secure` flag allows cleartext transmission; missing `HttpOnly` enables JavaScript access; overly broad `domain=` widens attack surface.
- **Session lifecycle code:** How are sessions created, refreshed, and destroyed?
→ Grep for: login handlers, logout endpoints, session invalidation calls (`session.invalidate()`, `session_destroy()`, `Session.Abandon()`)
→ WHY: Lifecycle gaps (no token rotation on login, no server-side invalidation on logout) are independent of token strength.
### Default Assumptions
- If transport protocol is not confirmed: assume mixed HTTP/HTTPS until verified — do not assume HTTPS everywhere without checking.
- If cookie flags are not visible: assume absent until confirmed present in `Set-Cookie` response headers.
- If logout implementation is unclear: test server-side invalidation explicitly — client-side cookie deletion is not sufficient.
## Process
Use `TodoWrite` to track assessment steps before beginning.
```
TodoWrite([
{ id: "1", content: "Identify session token(s) and transmission mechanism", status: "pending" },
{ id: "2", content: "Assess token generation: meaningful token analysis", status: "pending" },
{ id: "3", content: "Assess token generation: predictability analysis (concealed sequences, time dependency, weak PRNG)", status: "pending" },
{ id: "4", content: "Assess token generation: encrypted token analysis (ECB block rearrangement, CBC bit-flipping)", status: "pending" },
{ id: "5", content: "Run statistical randomness analysis via Burp Sequencer protocol", status: "pending" },
{ id: "6", content: "Assess token handling: network disclosure (HTTPS coverage, Secure flag, HTTP downgrade paths)", status: "pending" },
{ id: "7", content: "Assess token handling: log disclosure (URL-based tokens, admin monitoring exposure)", status: "pending" },
{ id: "8", content: "Assess token handling: token-to-session mapping (concurrent sessions, static tokens)", status: "pending" },
{ id: "9", content: "Assess token handling: session termination (expiration timeout, logout server-side invalidation)", status: "pending" },
{ id: "10", content: "Assess token handling: session fixation (4 test cases)", status: "pending" },
{ id: "11", content: "Assess token handling: CSRF exposure", status: "pending" },
{ id: "12", content: "Assess token handling: cookie scope (domain and path attributes)", status: "pending" },
{ id: "13", content: "Compile findings report with severity ratings and remediation", status: "pending" }
])
```
---
### Step 1: Identify Session Tokens and Transmission Mechanism
**ACTION:** Identify every item of data that functions as a session token. Do not assume the standard platform cookie is the only token — applications often use multiple items across cookies, URL parameters, and hidden form fields. Confirm which items are actually validated by the server for session state.
**WHY:** Applications may employ several items collectively as a token, using different components for different back-end subsystems. The standard session cookie generated by the web server may be present but not actually used. Additionally, an item that appears to be a session token may be ignored by the server, meaning its modification would go undetected — a finding in itself. Narrowing the actual validated components reduces wasted analysis effort on inert data.
**Detection method:**
1. Walk through the application from the start URL through the login function. Note every new item passed to the browser.
2. Find a page that is definitively session-dependent (e.g., "My Account" or "My Details") — one that returns content specific to the authenticated user.
3. Make repeated requests to that page, systematically removing each suspected token item. If removing an item causes the session-dependent content to disappear or redirect to login, the item is confirmed as a session token.
4. Use Burp Repeater or equivalent to perform this systematically.
**Also check for alternatives to sessions:**
- If token-like items are 100+ bytes, re-issued on every request, and appear encrypted or signed, the application may use sessionless state (transmitting all session data client-side). These require different testing — check for integrity protection and replay resistance rather than token prediction.
- If the application uses HTTP Basic/Digest/NTLM authentication without session cookies, session management attacks may not apply.
Mark Step 1 complete in TodoWrite.
---
### Step 2: Assess Token Generation — Meaningful Tokens
**ACTION:** Determine whether session tokens encode user-identifiable or predictable information (username, email, user ID, role, timestamp, IP address) in raw, encoded, or obfuscated form.
**WHY:** A token that encodes the username — even if hex-encoded or Base64-encoded — allows an attacker to construct valid tokens for any known user without interacting with the server. The apparent complexity of the token string is irrelevant if the underlying data is structured and user-specific.
**Test procedure:**
1. Obtain tokens for multiple different users by logging in with different accounts (use accounts with similar but slightly varying usernames: A, AA, AAA, AAAB, etc., to isolate the username component in the token).
2. Apply progressive decodings to each token and its components: hex decode → Base64 decode → XOR decode. Look for recognizable strings (usernames, email patterns, dates).
3. Look for structural indicators: only hexadecimal characters (possible hex encoding of ASCII), trailing `=` signs or charset `a-z A-Z 0-9 +/` (Base64 signatures), repeated character sequences matching username length.
4. Analyze correlations: do tokens for similar usernames share substrings? Does the token length vary with username length?
5. If tokens appear structured (delimiter-separated components), analyze each component independently. Some components may be random while others are meaningful.
**If meaning is found:**
- Determine whether the meaningful component is actually validated by the server (Step 1 procedure: modify that component and verify rejection).
- If validated: the application is directly vulnerable — an attacker can enumerate valid tokens for known usernames.
- If not validated: the component is decorative padding; remove it from further analysis.
Mark Step 2 complete in TodoWrite.
---
### Step 3: Assess Token Generation — Predictability
**ACTION:** Assess whether token values follow sequences that allow extrapolation to other users' tokens, even when the tokens do not contain meaningful user data. Investigate three predictability sources: concealed sequences, time dependency, and weak pseudorandom number generator (PRNG) output.
**WHY:** A token without meaningful user data can still be predictable if it follows an arithmetic sequence or is derived from observable inputs like the current time. An attacker who obtains a sample of tokens can reverse-engineer the generation algorithm and construct tokens issued to other users — without needing any user-specific information.
**3a. Concealed Sequences**
Tokens may appear random in raw form but reveal arithmetic sequences after decoding. Test:
1. Collect 10–20 consecutive tokens by rapidly triggering new session creation.
2. Apply decodings (Base64, hex) to each token and each structural component.
3. If the decoded output is binary, render as hexadecimal integers and compute differences between successive values.
4. Look for a repeating difference — this reveals the increment constant of the generation algorithm.
5. Once the constant is known, the full token sequence (past and future) is reconstructable.
**3b. Time Dependency**
Some token generation algorithms incorporate the current time (epoch milliseconds, microseconds) as a primary input. Test:
1. Collect two batches of tokens separated by a known time interval (e.g., 5–10 minutes apart).
2. In each batch, identify any component that increases monotonically but in variable increments.
3. Compare the difference between the last value of the first batch and the first value of the second batch. If the jump is consistent with the elapsed time (e.g., ~540,000 units in 9 minutes implies milliseconds), the component is time-based.
4. If source code is available, look for `System.currentTimeMillis()`, `time()`, `microtime()`, `Date.now()`, or similar time sources used in token construction.
5. Time-based components are brute-forceable: the range of valid values for a given user's token is bounded by the window of time around the user's login.
**3c. Weak PRNG**
Linear congruential generators (LCGs), `Math.random()`, `java.util.Random`, PHP's `rand()`, and similar non-cryptographic PRNGs produce sequences that are fully predictable from a small sample of output values. The next value (and all previous values) can be derived algebraically. Test:
1. If source code is available, check what randomness source is used: `SecureRandom`, `os.urandom`, `/dev/urandom`, `CryptGenRandom` are strong. `Random`, `Math.random()`, `rand()`, `mt_rand()` are weak.
2. If source code is unavailable, use Burp Sequencer statistical analysis (see Step 5) to measure effective entropy — weak PRNGs fail at many bit positions even when individual tokens appear visually random.
3. Check whether multiple PRNG outputs are concatenated to form a longer token. This is a common misconception: it does not increase entropy beyond the PRNG's internal state size, and may make state reconstruction easier by providing more sample values.
Mark Step 3 complete in TodoWrite.
---
### Step 4: Assess Token Generation — Encrypted Tokens
**ACTION:** Determine whether tokens are encrypted containers for meaningful data, and if so, test for ECB block rearrangement and CBC bit-flipping vulnerabilities.
**WHY:** Applications that encrypt meaningful session data (user ID, role, username) before issuing it as a token assume that encryption prevents tampering. This assumption fails for ECB ciphers (where ciphertext blocks can be rearranged to produce a different plaintext without knowing the key) and CBC ciphers (where bit-flipping a ciphertext byte produces predictable, controlled changes in the subsequent decrypted block).
**Detection — is a block cipher being used?**
1. Register accounts with usernames of increasing length (e.g., 1 character, 2 characters, etc., up to 20+ characters).
2. Monitor session token length. If the token length jumps by 8 or 16 bytes at a specific username length, a block cipher with 64-bit or 128-bit blocks is likely in use (8 bytes = 64-bit block cipher such as DES, 3DES; 16 bytes = 128-bit block cipher such as AES).
3. Confirm by continuing to add characters and observing the same jump occurring again 8 or 16 characters later.
**ECB mode test:**
1. ECB encrypts identical plaintext blocks into identical ciphertext blocks. Rearranging ciphertext blocks causes the corresponding plaintext blocks to be rearranged.
2. Register usernames specifically crafted so that one block of the username (at a known offset) aligns with a block containing a high-privilege field (e.g., UID or role field) in the token plaintext.
3. Duplicate that ciphertext block and insert it at the position of the target field.
4. Submit the modified token. If the application processes the request in the security context of a different user (or with elevated privileges), the ECB rearrangement attack succeeded.
5. Blind approach (no source code): try duplicating and moving ciphertext blocks, observing whether you remain logged in as yourself, become a different user, or are rejected.
**CBC mode test (bit-flipping):**
1. CBC decryption: flipping a bit in ciphertext block N corrupts block N entirely during decryption (renders it as garbage) but causes a predictable, controlled bit-flip in the corresponding position of block N+1's plaintext.
2. Use Burp Intruder's "bit flipper" payload type on the session token (treating it as ASCII hex). This generates ~8 requests per byte of token data — efficient for coverage.
3. Monitor responses for: (a) continued valid session but with a different user identity displayed (bit-flip hit a UID or role field in the following block), or (b) responses that indicate the application is processing corrupted but accepted token data.
4. When a bit-flip causes user context to change: perform a focused attack on that block position, iterating through a wider range of values to reach a target user ID or role.
5. Note: if the application rejects tokens containing invalid field values (e.g., non-numeric UID), the attack may be impractical. If the application only validates certain fields (e.g., only the UID), the attack targets those fields.
Mark Step 4 complete in TodoWrite.
---
### Step 5: Statistical Randomness Analysis — Burp Sequencer Protocol
**ACTION:** Run a structured statistical randomness test on the session token to quantify effective entropy in bits. This is the authoritative test for token generation quality when visual inspection or manual decoding does not reveal a pattern.
**WHY:** A token that passes visual inspection and manual analysis may still fail formal statistical randomness tests. Conversely, a token that fails statistical tests may not be practically predictable if the failing bits are sparse across many positions. The key metric is effective entropy (bits of the token that pass randomness tests): a 50-bit token with 50 random bits is equivalent to a 1,000-bit token with only 50 random bits.
**Collection protocol:**
1. Identify the request that issues a new session token (typically: `GET /` unauthenticated, or `POST /login` after authentication). Send this request to Burp Sequencer via the context menu.
2. Configure Sequencer: select the cookie name or form field containing the session token; set boundary markers if using manual selection.
3. Enable "auto analyse" to trigger analysis at intervals.
4. **Sample size milestones:**
- 100 tokens: minimum for any analysis. Collect before reviewing results in detail.
- 500 tokens: sufficient to detect clear failures. If analysis at this point shows convincing failures, no need to continue.
- 5,000 tokens: adequate for most assessments; tokens that pass here are unlikely to be practically predictable.
- 20,000 tokens: required for full FIPS 140-2 compliance testing. Maximum sample size Burp Sequencer supports.
5. If source IP or username influences token generation, repeat token collection from a different IP address and/or username and compare results to isolate IP/username as an entropy source.
**Interpreting Burp Sequencer results:**
- **Effective entropy (bits):** The headline result. Values below 64 bits indicate weakness for most application contexts; below 32 bits is critically weak.
- **FIPS test results:** Six standardized tests (monobit, poker, runs, long runs, serial correlation, spectral). Failing multiple FIPS tests at many bit positions indicates structural non-randomness.
- **Character-level vs bit-level analysis:** Burp tests at both levels. Large structured portions of a token (e.g., a fixed prefix, a user ID field) are not random — this is expected and not a vulnerability in itself. What matters is whether the random portion provides sufficient entropy.
**Important caveats:**
- A token generated by a weak but algorithmically deterministic PRNG (e.g., a linear congruential generator) may pass all statistical tests while being fully predictable from a small sample. Statistical tests measure distribution, not algorithmic predictability.
- A token that fails statistical tests at a few bit positions may not be practically exploitable if the failure involves only a small number of bits that an attacker would need to simultaneously predict correctly.
Mark Step 5 complete in TodoWrite.
---
### Step 6: Assess Token Handling — Network Disclosure
**ACTION:** Verify that session tokens are never transmitted in cleartext over unencrypted HTTP, and that cookie `Secure` flags are correctly set to enforce this.
**WHY:** A network eavesdropper positioned at any point between client and server — the user's local network, corporate network, ISP, hosting provider — can capture cleartext HTTP traffic. A captured session token grants full session access without knowing user credentials. Even applications that use HTTPS for most content frequently have specific paths (static assets, pre-authentication pages, login forms that accept HTTP) that leak the session token.
**Test procedure:**
1. Walk through the complete application lifecycle: unauthenticated access (start URL), login process, all authenticated functionality. Record every URL and every instance in which a new session token is received or existing token is transmitted. Use Burp Proxy HTTP history for this.
2. Check `Set-Cookie` headers for the `Secure` flag. If `Secure` is absent, the browser will transmit the cookie over HTTP to any path/domain match, including unencrypted requests.
3. Verify whether the application switches from HTTP to HTTPS at any point. If it does:
a. Check whether a session token issued before the HTTPS switch is reused in the authenticated session (pre-authentication token reuse).
b. Verify whether the application also accepts login over plain HTTP if the login URL is accessed directly with `http://` instead of `https://`.
4. Even if HTTPS is used everywhere for the application itself: verify whether the server also listens on port 80. If so, visit any authenticated page URL using `http://` and check whether the token is transmitted.
5. If any static content (images, scripts, stylesheets) is loaded over HTTP from within an HTTPS-delivered page, the session cookie is transmitted with those HTTP requests (no `Secure` flag) or the browser warns (mixed content). Treat either as a vulnerability.
6. If a token for an authenticated session is transmitted over HTTP: verify whether the server immediately invalidates that token upon detecting the insecure transmission. If not, the token remains valid for hijacking.
Mark Step 6 complete in TodoWrite.
---
### Step 7: Assess Token Handling — Log Disclosure
**ACTION:** Identify whether session tokens can be read from system logs, monitoring interfaces, or referrer headers due to token transmission in URLs.
**WHY:** URL-embedded session tokens appear in: web server access logs, browser history, corporate proxy logs, ISP proxy logs, `Referer` headers sent to third-party servers when the user follows an off-site link from within the authenticated session. Log disclosure differs from network disclosure in that it is often accessible to a much wider range of insiders (helpdesk, IT operations, log aggregation system users) and persists across time.
**Test procedure:**
1. Walk through all application functionality and identify any instances where session tokens appear in URL query strings or path components (e.g., `jsessionid=` in the URL path, `token=` in query parameters). Grep for: `inurl:jsessionid`, `?token=`, `?session=` patterns in captured traffic.
2. Identify any administrative, helpdesk, or diagnostic functionality within the application that allows viewing user sessions. Access that functionality with your test account and check whether the actual session token value is displayed. If it is, verify who can access this functionality — anonymous users, any authenticated user, or only administrators.
3. If tokens appear in URLs: attempt to inject an off-site link (via any user-controlled content feature — message boards, profile fields, feedback forms). Monitor the attacker-controlled server's access logs for incoming `Referer` headers containing session tokens from other users.
Mark Step 7 complete in TodoWrite.
---
### Step 8: Assess Token Handling — Vulnerable Token-to-Session Mapping
**ACTION:** Test whether the application correctly maps tokens to sessions, preventing concurrent session abuse and static token reuse.
**WHY:** Even a cryptographically strong token is useless as a security control if the application accepts multiple concurrent valid tokens for the same user, or issues the same token on every login ("static tokens"). Concurrent sessions allow an attacker who has obtained credentials to use a captured token undetected while the legitimate user is also logged in. Static tokens are permanent access credentials, not sessions — compromising them compromises the account permanently.
**Test procedure:**
1. **Concurrent session test:** Log in to the application twice simultaneously using the same user account, from different browser processes or machines. Determine whether both sessions remain active concurrently. If yes: concurrent sessions are permitted. An attacker who has compromised credentials can use them without triggering a conflict.
2. **Static token test:** Log in and log out of the same account multiple times, from different browser processes or machines. Record the session token issued on each login. If the same token is issued on every login: the application is using static tokens. These are not sessions in the security sense — they function as permanent credentials.
3. **Segmented token test (structured tokens only):** If tokens contain both user-identifying components and apparently random components, modify the user-identifying component to refer to a different known user while submitting any valid random component. If the server accepts the modified token and processes the request in the context of the different user: the application has a fundamental token-to-session mapping vulnerability (the user context is determined by user-supplied data outside the session).
Mark Step 8 complete in TodoWrite.
---
### Step 9: Assess Token Handling — Session Termination
**ACTION:** Verify that sessions expire after an appropriate inactivity timeout and that logout actually invalidates the session on the server side.
**WHY:** A long-lived session token extends the attack window — if a token is captured or guessed, it remains valid for use. A logout function that only deletes the browser cookie without invalidating the server-side session is functionally equivalent to no logout: anyone who captured the token before logout can still use it indefinitely. Client-side cookie blanking is not server-side invalidation.
**Test procedure:**
1. **Inactivity timeout test:**
a. Log in and obtain a valid session token.
b. Wait for the intended inactivity period without making any requests (e.g., 10–30 minutes, depending on the application's stated policy).
c. Submit a request for a protected page using the token.
d. If the page renders normally: the inactivity timeout is not enforced or is longer than expected.
e. Use Burp Intruder to automate: configure increasing time intervals between successive requests using the same token to find the timeout boundary.
2. **Logout invalidation test:**
a. Log in and record a session-dependent request (e.g., GET to "My Account") in Burp Proxy history.
b. Perform the logout action in the application.
c. Send the recorded session-dependent request again using the pre-logout token (via Burp Repeater).
d. If the session-dependent page renders successfully: the logout did not invalidate the server-side session.
3. **Client-side vs server-side test:** Examine what the logout response actually does: does it issue a `Set-Cookie` with a blank or expired token value (client-side only), or does it call a server-side invalidation function? Source code review is definitive. If no source code: the Repeater test in step 2 is authoritative.
Mark Step 9 complete in TodoWrite.
---
### Step 10: Assess Token Handling — Session Fixation
**ACTION:** Test four specific scenarios that determine whether an attacker can fix a known token value for a victim, then escalate to authenticated access after the victim logs in.
**WHY:** Session fixation attacks are possible when an application accepts tokens that it did not itself issue, or when it reuses pre-authentication tokens as post-authentication tokens. The attacker supplies a token to the victim (via URL parameter, cookie injection, or simply knowing the format), the victim logs in, and the attacker then uses the known token to access the victim's authenticated session.
**Test procedure — four test cases:**
1. **Pre-authentication token reuse:** If the application issues session tokens to unauthenticated users (e.g., to track anonymous shopping carts), obtain an unauthenticated token and perform a login. If the application does not issue a new token after successful authentication: it is vulnerable. An attacker can obtain an anonymous token, force the victim to use it (URL fixation), and after the victim logs in, use the same token.
2. **Return-to-login token reuse:** Log in to obtain an authenticated token. Return to the login page. If the application serves the login page without issuing a new token (the existing authenticated token is still active): log in again as a different user using the same token. If the application does not issue a new token on the second login: it is vulnerable to fixation between accounts.
3. **Attacker-supplied token acceptance:** Identify the format of valid tokens (from Step 1). Construct a token that conforms to the format (correct length, character set) but is an invented value the application did not issue. Attempt to log in while submitting this invented token in the expected location. If the application creates an authenticated session tied to the invented token: the application accepts attacker-supplied tokens, enabling fixation.
4. **Sensitive data fixation (non-login applications):** If the application does not use authentication but processes sensitive user data (e.g., payment forms, personal details), apply test cases 1 and 3 in relation to the pages that display submitted sensitive data. If a token set during anonymous usage can be used by another party to retrieve that user's sensitive data: the application is vulnerable to fixation against non-authenticated sensitive operations.
**Cross-site request forgery (CSRF) check:**
If the application transmits session tokens via cookies: confirm whether it is protected against CSRF.
1. Log in to the application and identify state-changing operations whose parameters an attacker could determine in advance (fund transfers, password changes, data deletions).
2. From a different browser tab or window in the same browser process, construct a request to that operation (via a crafted form or link) that would originate from a page on a different domain.
3. If the application processes the cross-origin request and executes the state change: it is vulnerable to CSRF. The browser submits the cookie automatically regardless of the request origin.
4. Check for CSRF tokens: does the application include a per-request unpredictable token in a hidden form field or custom header that the server validates? If the application relies solely on cookies and has no CSRF token: assume vulnerable.
Mark Step 10 complete in TodoWrite.
---
### Step 11: Assess Token Handling — Cookie Scope
**ACTION:** Review all `Set-Cookie` response headers for `domain` and `path` attributes. Determine whether cookie scope is more permissive than necessary, exposing session tokens to other applications or subdomains.
**WHY:** A cookie scoped to `wahh-organization.com` is submitted to every subdomain of that organization — including test environments, staging systems, and other applications that may have lower security standards or be accessible to different personnel. A cross-site scripting vulnerability in any application within the cookie's scope can steal tokens from the main application. Cookie scope is often configured at the platform level (web server defaults) rather than by application developers, so it may be unnecessarily broad.
**Test procedure:**
1. Review all `Set-Cookie` headers issued by the application across the full application walkthrough. Note the `domain` and `path` values for session token cookies.
2. If `domain` is set: it is more permissive than the default (which scopes cookies to the exact hostname). Identify all subdomains and applications within the specified domain. Any of these can receive the session cookie.
3. If no `domain` is set: by default, the browser scopes the cookie to the exact hostname. However, subdomains still receive the cookie (e.g., a cookie set by `app.example.com` with no domain attribute is still sent to `app.example.com`, not to `other.example.com`, but default behavior differs by browser implementation — verify).
4. If `path` is set to `/` or a broad path: path-based scope restriction provides no meaningful security separation between applications at different URL paths on the same hostname. Client-side JavaScript at any path on the same origin can read cookies regardless of `path` attribute.
5. Identify all web applications accessible via the domains that will receive the session cookie. Assess their security posture — a stored cross-site scripting vulnerability in any of them could steal tokens from the primary application.
Mark Step 11 complete in TodoWrite.
---
### Step 12: Compile Findings Report
**ACTION:** Consolidate all findings from Steps 2–11 into a structured vulnerability report with severity ratings and remediation guidance.
**WHY:** A finding without remediation guidance is incomplete. Each vulnerability class has a corresponding countermeasure; mapping findings to remediations allows the development team to act without additional research.
**HANDOFF TO HUMAN** — the agent produces the report; the security team or development team prioritizes and implements remediations.
**Report format:**
```markdown
# Session Management Security Assessment Report
## Assessment Scope
[Application name, test date, authorization basis, artifacts reviewed]
## Session Token Identification
[Which items function as session tokens, transmission mechanism, alternatives-to-sessions assessment]
## Part 1: Token Generation Weaknesses
### G1: Meaningful Token Content
**Finding:** [Present / Not detected]
**Evidence:** [Decoded token values, correlation with user data]
**Severity:** [Critical if exploitable | Informational if not validated by server]
**Remediation:** Tokens should be opaque server-generated identifiers. Move all session data to server-side session storage. Never encode user-identifiable data in tokens.
### G2: Predictable Token Sequences
**Finding:** [Present / Not detected — specify: concealed sequence / time dependency / weak PRNG]
**Evidence:** [Sample tokens, decoded sequences, difference analysis, PRNG identification]
**Severity:** [Critical if directly exploitable | High if requires timing correlation]
**Remediation:** Use a cryptographically secure PRNG (CSPRNG) seeded from a high-entropy source (e.g., `SecureRandom`, `os.urandom`, `CryptGenRandom`). Do not use time as a primary entropy source. Do not use linear congruential generators.
### G3: Encrypted Token Vulnerabilities
**Finding:** [ECB block rearrangement / CBC bit-flipping / Not detected]
**Evidence:** [Block cipher detection evidence, manipulation results]
**Severity:** [High — privilege escalation or cross-user access]
**Remediation:** Tokens should not encode sensitive data at all. If encrypted tokens are required, use authenticated encryption (AES-GCM, ChaCha20-Poly1305) to detect any ciphertext modification. Do not use ECB mode. Verify that the entire ciphertext is authenticated before processing any field.
### G4: Statistical Entropy Assessment (Burp Sequencer)
**Finding:** [Effective entropy: X bits. FIPS tests: passed/failed. Notable failures: ...]
**Severity:** [Critical if < 32 bits effective | High if < 64 bits | Low if >= 128 bits]
**Remediation:** Target >= 128 bits of effective entropy. Use platform-provided session management (mature frameworks implement this correctly) rather than custom token generation.
## Part 2: Token Handling Weaknesses
### H1: Network Disclosure
**Finding:** [Cleartext transmission detected / Secure flag absent / HTTP downgrade path found / Not detected]
**Remediation:** Transmit tokens exclusively over HTTPS. Set `Secure` flag on all session cookies. Use HSTS. Redirect HTTP to HTTPS and invalidate any token transmitted over HTTP. Issue a fresh token after the HTTP-to-HTTPS transition.
### H2: Log Disclosure
**Finding:** [Token in URL / Admin monitoring exposes token / Not detected]
**Remediation:** Never transmit session tokens in URL query strings or path components. Use POST for token submission or store in cookies. Administrative monitoring functions should display session metadata (user ID, IP, login time) without exposing the token value itself.
### H3: Vulnerable Token-to-Session Mapping
**Finding:** [Concurrent sessions permitted / Static tokens / Segmented token vulnerability / Not detected]
**Remediation:** Issue a unique token per session. Invalidate all existing sessions when a new login occurs (or alert the user of concurrent access). Never reissue the same token to the same user across separate login events.
### H4: Vulnerable Session Termination
**Finding:** [No inactivity timeout / Logout does not invalidate server-side / Client-side-only cookie deletion / Not detected]
**Remediation:** Implement server-side session invalidation on logout that disposes of all session resources and marks the token as invalid. Implement server-side inactivity timeout (10–30 minutes is typical; match business requirements). Do not rely on client-side cookie deletion as the primary termination mechanism.
### H5: Session Fixation
**Finding:** [Pre-authentication token reused / Return-to-login reuse / Attacker-supplied token accepted / Sensitive data fixation / Not detected]
**Remediation:** Issue a fresh session token immediately after successful authentication. Reject tokens that the server did not itself generate. For non-authenticated sensitive data flows, create a new session at the start of the sensitive data sequence.
### H6: Cross-Site Request Forgery
**Finding:** [Vulnerable — state-changing operations accept cross-origin requests without CSRF token / Not detected]
**Remediation:** Implement per-request CSRF tokens in hidden form fields. Validate the CSRF token on every state-changing request. Consider using the `SameSite=Strict` or `SameSite=Lax` cookie attribute. Require re-authentication before critical operations (fund transfers, password changes).
### H7: Overly Liberal Cookie Scope
**Finding:** [Domain attribute broadens scope to: [list domains] / Path attribute is ineffective for security isolation / Not detected]
**Remediation:** Do not set `domain` attribute unless required — the default (exact hostname) is more restrictive. If subdomains must receive the cookie, audit every subdomain for cross-site scripting and other vulnerabilities. Set cookie scope as restrictively as feasible. Prefer `HttpOnly` to reduce JavaScript access.
## Summary
| # | Weakness | Severity | Status |
|---|----------|----------|--------|
| G1 | Meaningful token content | | |
| G2 | Predictable sequences | | |
| G3 | Encrypted token vulnerability | | |
| G4 | Insufficient entropy | | |
| H1 | Network disclosure | | |
| H2 | Log disclosure | | |
| H3 | Token-to-session mapping | | |
| H4 | Session termination | | |
| H5 | Session fixation | | |
| H6 | CSRF | | |
| H7 | Cookie scope | | |
**Priority remediations:**
1. [Most critical — typically: token generation or network disclosure]
2. [Second priority]
3. [Third priority]
**Positive findings:** [Aspects confirmed secure]
```
Mark Step 12 complete in TodoWrite.
## Key Principles
- **Token generation and token handling are independent failure dimensions.** A cryptographically strong token can still be stolen via network interception, log exposure, or session fixation. A token that is never disclosed can still be useless as a security control if the session lifecycle is broken. Assess both dimensions fully, not just whichever is easier.
- **Statistical randomness tests do not prove cryptographic security.** A deterministic algorithm (linear congruential generator, hash of sequential counter) can produce output that passes all FIPS statistical tests while being perfectly predictable by an attacker who knows the algorithm. Effective entropy is a necessary condition, not a sufficient one. Always investigate the generation algorithm in source code when available.
- **Passing visual inspection is not passing a security test.** Session tokens that "look random" to the eye have repeatedly proven predictable under analysis. Structured statistical analysis (Burp Sequencer at 500+ tokens) and algorithmic analysis (source code review) are required for a defensible assessment.
- **The Secure flag and HTTPS coverage must both be confirmed.** An application that uses HTTPS for all its own pages but loads a single static resource over HTTP exposes the session cookie to network capture on that one HTTP request. Coverage must be total, not partial.
- **Server-side invalidation is the only valid form of logout.** Any logout implementation that relies solely on the client deleting its cookie provides no security against an attacker who has already captured the token. Test logout by replaying a captured pre-logout request after the logout action.
- **Cookie scope is often set at the platform level, not the application level.** Platform defaults may scope cookies to a parent domain across all subdomains. The developer may be unaware. Always check `domain` and `path` attributes explicitly in the `Set-Cookie` response headers, not in application code.
- **Encrypted tokens are not safe from tampering without authentication.** ECB mode allows block rearrangement without decryption. CBC mode allows controlled plaintext modification without decryption. Only authenticated encryption (AEAD) prevents ciphertext manipulation. If tokens must encrypt meaningful data, AES-GCM with verification of the authentication tag before any field is processed is the minimum acceptable approach.
## Examples
**Scenario: E-commerce application — suspected meaningful token**
Trigger: "Our session tokens look like random hex strings but I want to verify they don't encode user data."
Process:
1. Collect tokens for 5 test accounts: usernames `a`, `aa`, `aaa`, `b`, `[email protected]`.
2. Hex-decode each token. Token for `[email protected]` decodes to a semicolon-delimited string: `[email protected];app=shop;date=2026-04-06`. This is a meaningful token.
3. Verify: modify the `user=` component to a different registered email. Submit to a session-dependent page. Application responds with the other user's account data.
4. Confirmed: meaningful token content, directly exploitable for horizontal privilege escalation across all registered accounts.
Output: Critical G1 finding. Remediation: move to opaque server-generated session identifiers; store all session data server-side.
---
**Scenario: Banking application — logout verification**
Trigger: "Verify whether our logout actually terminates sessions."
Process:
1. Log in, navigate to "My Account" page. Record the GET request in Burp Proxy.
2. Send that GET request to Burp Repeater. Confirm it returns account data.
3. Perform logout action via the application UI.
4. In Burp Repeater, re-send the same GET request with the pre-logout session cookie.
5. Application returns: HTTP 200 with full account data. The session token is still valid after logout.
6. Examine logout response: server issues `Set-Cookie: sessionId=; expires=Thu, 01 Jan 1970 00:00:00 GMT` — a client-side cookie deletion only. No server-side invalidation call occurs.
Output: High H4 finding. Remediation: implement server-side session invalidation on logout; store session state on server with explicit invalidation on logout request.
---
**Scenario: Internal application — Burp Sequencer entropy assessment**
Trigger: "Custom session token generation was built in-house using Java. Assess token quality."
Process:
1. Identify the login POST endpoint as the token issuance point. Send to Burp Sequencer, configure for the `sessionId` cookie.
2. Collect 100 tokens: preliminary analysis shows effective entropy ~32 bits. Several FIPS tests fail at low bit positions.
3. Collect 500 tokens: entropy estimate stabilizes at 28 bits. FIPS monobit and runs tests fail at positions 0–6.
4. Source code review (available): `String sessId = Integer.toString(s_SessionIndex++) + "-" + System.currentTimeMillis();` — a sequential counter concatenated with epoch milliseconds. The counter is the primary failure cause; milliseconds provide only limited additional entropy during busy periods.
5. Confirmed: time-dependent sequential generation with low effective entropy. G2 and G4 findings.
Output: Critical G2 (time dependency + sequential counter) and Critical G4 (28-bit effective entropy) findings. Remediation: replace with `java.security.SecureRandom` generating 128-bit random tokens; store all session data in a server-side session store keyed by this token.
## References
- For token generation countermeasure implementation details, see [references/securing-session-management.md](references/securing-session-management.md)
- For cookie attribute reference and browser behavior matrix, see [references/cookie-security-attributes.md](references/cookie-security-attributes.md)
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- CWE-330: Use of Insufficiently Random Values; CWE-384: Session Fixation; CWE-352: Cross-Site Request Forgery
- Source: *The Web Application Hacker's Handbook*, 2nd ed., Stuttard & Pinto, Chapter 7, pp. 205–255
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Web Application Hackers Handbook by Unknown.
## Related BookForge Skills
This skill is standalone. Browse more BookForge skills: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
支持同时输入多个艺人名称,自动查找各自的演唱会/巡演信息,智能识别时间和地点相近的演出组合,规划一次出行看多场演出的最优方案,并搜索对应的往返机票和演出场馆附近酒店。适用于想在一次旅途中连看多位艺人演出的用户。
---
name: multi-concert-trip-planner
description: 支持同时输入多个艺人名称,自动查找各自的演唱会/巡演信息,智能识别时间和地点相近的演出组合,规划一次出行看多场演出的最优方案,并搜索对应的往返机票和演出场馆附近酒店。适用于想在一次旅途中连看多位艺人演出的用户。
---
# Multi-Concert Trip Planner
支持多个艺人名称输入,自动在全球巡演信息中发现时间与地点相近的演出组合,帮助用户一次出行看多场演出,并搜索对应的往返机票和场馆附近酒店,输出完整的追星出行方案。
## 核心能力
- 同时接收多个艺人名称,并行搜索各自的巡演信息
- 三层信息采集:WebSearch 摘要 → WebFetch 可靠站点 → agent-browser 浏览器渲染(处理 JS 动态页面)
- 自动发现"时间窗口"内同城市或相邻城市的演出组合
- 按组合的紧凑程度和总花费排序推荐
- 为每个推荐组合搜索往返机票,输出一站式出行方案
- 为每个推荐组合搜索演出场馆附近酒店(飞猪实时报价),自动匹配入住/退房日期
- 场次变更追踪:自动与上次搜索结果做 diff,总结新增/取消/变更的场次
## 文件结构
| 文件 | 内容 |
|------|------|
| `SKILL.md`(本文件) | 工作流程总览、参数收集、综合推荐逻辑、注意事项 |
| `concert-search.md` | 第二步:演唱会搜索策略、WebFetch 规则、提取字段 |
| `combination-matching.md` | 第三步:组合匹配算法、评分权重、都市圈参考表 |
| `flight-search.md` | 第四步:flyai 机票搜索命令、返回数据解析 |
| `hotel-search.md` | 第四步-B:flyai 酒店搜索命令、返回数据解析、筛选策略 |
| `output-template.md` | 第六步:完整输出格式模板 + 特殊场景处理 + 场次变更总结 |
| `examples.md` | 7 个交互示例(多艺人+机票+酒店、单艺人、跨城、星级预算、仅酒店、agent-browser、变更追踪) |
| `BLOCKED_SITES.md` | WebFetch 失败站点记录(持续更新) |
| `diff-tracking.md` | 场次变更追踪:快照存储格式、diff 算法、变更分类规则 |
## 工作流程
### 第一步:收集用户需求
从用户请求中提取以下参数:
- **艺人/乐队名称列表**(必填,至少 1 个)— 如用户只给了 1 个艺人,正常执行搜索(退化为单艺人模式,跳过第三步的组合匹配);如给了多个艺人,则进入多艺人组合匹配流程
- **出发城市**(仅开启机票搜索时必填 — 如用户未提供且需要搜索机票,必须主动询问)
- **时间窗口偏好**(可选,默认"未来 6 个月")— 如"今年夏天"、"下半年"、"8-10 月"
- **组合容忍天数**(可选,默认 7 天)— 两场演出之间最多间隔多少天仍视为"可组合",用户可以说"最好 1 天内"或"一周内都行"。询问时提供的选项应包含 1 天(仅限连续两天)、3 天内、7 天内、14 天内等梯度
- **是否搜索机票**(可选,默认关闭)— 机票价格来自搜索引擎摘要,仅供粗略参考,准确性有限。询问时默认关闭,用户明确要求时才开启
- **是否搜索酒店**(可选,默认关闭)— 使用飞猪搜索演出场馆附近酒店,返回实时价格和预订链接。询问时默认关闭,用户明确要求时才开启
- **酒店偏好**(可选,仅开启酒店搜索时有效)— 包括:
- 星级偏好:如"四星以上"、"经济型就好"
- 每晚预算上限:如"每晚不超过 800"
- 床型偏好:如"大床房"、"双床房"
- 酒店类型:hotel(酒店)、homestay(民宿)、inn(客栈),默认 hotel
- **预算范围**(可选,仅开启机票或酒店搜索时有效)— 如"总花费 1 万以内"、"越便宜越好"
- **地区偏好**(可选)— 如"只看亚洲"、"优先日本和韩国"、"不限地区"
若用户只提供了艺人列表,至少追问出发城市。
### 第一步-B:加载上次搜索快照
→ 详见 `diff-tracking.md`
在开始搜索前,根据本次艺人列表在 `snapshots/` 目录中查找最近一次匹配的快照。如找到,加载为 `previousSnapshot` 用于搜索完成后的 diff 对比。首次搜索该艺人组合时跳过此步。
### 第二步:并行查找各艺人演唱会信息
→ 详见 `concert-search.md`
对每个艺人使用 WebSearch 搜索巡演信息(日/英/中三语查询),通过 Task 工具并行执行。采用三层降级策略:优先从搜索摘要提取信息,对可靠站点使用 WebFetch 补充,对 JS 渲染站点使用 agent-browser 浏览器抓取。结果去重、按日期排序,过滤 14 天内场次。
### 第三步:智能组合匹配(核心逻辑)
→ 详见 `combination-matching.md`
单艺人模式跳过本步骤。多艺人模式下,用滑动窗口在时间线上扫描,按城市/都市圈分组,生成候选组合并按四维评分(艺人覆盖 40%、时间紧凑 25%、地理集中 20%、售票可行 15%)排序,取前 10 个组合。
### 第四步:搜索往返机票(可选,默认跳过)
→ 详见 `flight-search.md`
仅在用户明确要求时执行。使用 `flyai search-flight` 搜索往返机票,返回实时价格和飞猪购票链接。多组合并行搜索,每组合取最便宜的 3 个选项。
### 第四步-B:搜索演出场馆附近酒店(可选,默认跳过)
→ 详见 `hotel-search.md`
仅在用户明确要求时执行。使用 `flyai search-hotel` 以场馆名称为关键词搜索附近酒店,返回实时价格和飞猪预订链接。多城市并行搜索,每城市取前 5 家,按档次分层推荐。
### 第五步:综合整理与推荐
**如果开启了机票和/或酒店搜索:** 将组合信息、机票信息、酒店信息和城际交通(如有)合并,计算每个方案的总花费估算(总花费 = 各场门票之和 + 往返机票 + 住宿费用 + 城际交通)。
**如果未开启机票和酒店搜索(默认):** 仅基于演出信息整理推荐,不涉及机票、酒店和总花费。
**推荐逻辑:**
- 首要指标:能覆盖的艺人数量(越多越好)
- 次要指标:时间紧凑度(间隔天数越少越好)
- 参考指标:地理集中度、售票可行性
- 如有机票数据,额外参考总花费
- 标注"最佳覆盖"(看到最多艺人)和"最紧凑"(间隔最短)方案
### 第六步:呈现总结
→ 详见 `output-template.md`
按标准模板输出方案(演出安排表 + 购票链接 + 机票信息 + 酒店推荐 + 总花费估算),并处理特殊场景(艺人无演出、无可组合方案、音乐节等)。
### 第七步:保存快照 + 场次变更总结
→ 详见 `diff-tracking.md`
将本次搜索结果保存为 JSON 快照文件。如存在上次快照(第一步-B 加载),自动执行 diff 对比,在输出末尾生成「场次变更总结」,包含新增/取消/场馆变更/售票状态变更/票价变更 5 类变化。首次搜索时仅保存快照并提示用户。
## 注意事项
- 出发城市仅在用户开启机票搜索时需要,不要在未开启时追问。
- 为提升效率,多个艺人的搜索必须使用 Task 工具并行执行,而非逐一串行搜索。
- agent-browser 是最重量级的信息采集手段,仅在 WebSearch 摘要和 WebFetch 都无法获取关键信息时使用。每次使用后及时 `agent-browser close` 释放资源。详见 `concert-search.md` 第三层策略和 `BLOCKED_SITES.md` 中标记为 🟢 的站点。
- 机票价格波动较大,提醒用户价格仅供参考,建议尽早预订。
- 酒店价格同样会波动(尤其是演唱会期间热门城市),提醒用户看到合适的酒店尽早预订。
- 搜索酒店时优先使用场馆名称作为 `--key-words`,确保推荐的酒店距离场馆较近,方便观演。
- 如果场馆关键词搜索结果较少,退而使用城市核心区域(如"新宿"、"涩谷"、"梅田")作为关键词补充搜索。
- 转售平台(StubHub 等)的门票价格可能高于原价,需标注说明。
- 搜索机票时考虑演出城市对应的主要机场(如东京对应 NRT/HND,伦敦对应 LHR/LGW/STN)。
- 默认展示最多 5 个组合方案 + 未能组合的场次,除非用户要求更多。
- 尊重各网站的请求限制,合理控制搜索频率。
- 如果用户指定了预算,优先过滤掉超出预算的方案。
- 组合评分算法中的权重为默认值,如用户明确偏好(如"我更在乎省钱"),应动态调整权重。
- 每次搜索结束后必须保存快照到 `snapshots/` 目录。如存在上次快照,必须在输出末尾附上场次变更总结。详见 `diff-tracking.md`。
## 交互示例
→ 详见 `examples.md`(含 7 个完整场景:多艺人+机票+酒店、单艺人+机票、多艺人跨城、带星级预算的酒店搜索、仅搜酒店不搜机票、agent-browser 处理 JS 渲染官网、场次变更追踪 diff)
FILE:examples.md
# 交互示例
## 示例 1:多艺人 + 机票 + 酒店
**用户**:"我想看 YOASOBI 和 Ado 的演唱会,最好能一趟都看了,帮我查查"
**执行步骤**:
1. 追问:出发城市是哪里?时间范围有偏好吗?要搜索机票和酒店吗?
2. 用户回答:从上海出发,今年下半年,帮我搜机票和酒店
3. 使用 Task 工具并行搜索 YOASOBI 和 Ado 的巡演信息
4. 汇总所有场次,执行组合匹配:发现两者在 8 月都有东京场次且相隔 3 天、10 月都有大阪场次且相隔 5 天
5. 对每个组合并行搜索:上海→东京/大阪的往返机票 + 场馆附近酒店
6. 综合排序,输出包含两位艺人演出 + 机票 + 酒店的组合出行方案
7. 将无法组合的场次(如 Ado 的欧洲场次)单独列出供参考
---
## 示例 2:单艺人 + 机票(无酒店)
**用户**:"我想看 Ado 的演唱会,从深圳出发"
**执行步骤**:
1. 单艺人模式,无需组合匹配
2. 搜索 Ado 的巡演信息,找到东京、大阪、首尔、台北等场次
3. 分别搜索深圳→东京、深圳→大阪、深圳→首尔、深圳→台北的往返机票
4. 按总花费(门票 + 机票)排序,输出最优方案
---
## 示例 3:多艺人组合匹配 + 跨城交通
**用户**:"帮我看看 Coldplay、Bruno Mars、Ed Sheeran 最近在亚洲有没有时间撞上的演唱会,我从北京出发"
**执行步骤**:
1. 参数明确,直接开始搜索
2. 使用 Task 工具并行搜索三位艺人的亚洲巡演信息
3. 汇总后发现:Coldplay 11月在东京、Bruno Mars 11月在东京(相隔2天)、Ed Sheeran 11月在首尔
4. 生成组合:
- 组合A(⭐最佳):东京看 Coldplay + Bruno Mars(间隔2天,同城)
- 组合B:东京 Coldplay + 首尔 Ed Sheeran(间隔5天,需跨城)
- 组合C:三人全覆盖 — 东京2场 + 首尔1场(需额外东京→首尔交通)
5. 分别搜索机票和城际交通,输出完整方案
---
## 示例 4:单艺人 + 机票 + 酒店(带星级和预算)
**用户**:"我想看 Ado 的演唱会,从深圳出发,帮我搜一下机票和酒店,酒店要四星以上,每晚预算 1500 以内"
**执行步骤**:
1. 单艺人模式,无需组合匹配
2. 搜索 Ado 的巡演信息,找到东京、大阪、首尔、台北等场次
3. 并行搜索:
- 机票:深圳→东京、深圳→大阪、深圳→首尔、深圳→台北的往返机票
- 酒店:每个城市对应场馆附近的酒店(`--hotel-stars 4,5 --max-price 1500`)
4. 按总花费(门票 + 机票 + 住宿)排序,输出最优方案
---
## 示例 5:只搜酒店,不搜机票
**用户**:"查一下周杰伦巡演,只需要搜酒店不用搜机票,住便宜点的民宿就行"
**执行步骤**:
1. 单艺人模式,仅开启酒店搜索(不开启机票搜索)
2. 搜索周杰伦的巡演信息
3. 对每个有效场次搜索场馆附近民宿(`--hotel-types homestay --sort price_asc`)
4. 输出演出信息 + 每个城市的民宿推荐(不含机票信息)
---
## 示例 6:agent-browser 处理 JS 渲染官网
**用户**:"帮我查米津玄師和绿黄色社会下半年的演唱会,从北京出发,搜机票和酒店"
**执行步骤**:
1. 使用 Task 工具并行搜索两位艺人的巡演信息
2. WebSearch 搜索摘要中获取了米津玄師的部分日程,但缺少详细场馆和售票信息
3. 发现 ticket.kenshiyonezu.jp 是官方售票页面(BLOCKED_SITES.md 标记为 JS 渲染站点 🟢),启用 agent-browser:
```bash
agent-browser open https://ticket.kenshiyonezu.jp/pages/2026_detail
agent-browser wait 3000
agent-browser snapshot -i
# 从快照中提取完整日程(日期、场馆、票价、售票状态)
agent-browser close
```
4. 绿黄色社会的官网 ryokushaka.com/live/ 同样是 JS 渲染,agent-browser 抓取补充
5. 汇总所有场次,执行组合匹配 + 机票酒店搜索,输出完整方案
---
## 示例 7:场次变更追踪(diff)
**用户**:"再帮我查一下米津玄師和绿黄色社会下半年的演唱会"(此前已搜索过同一组艺人)
**执行步骤**:
1. 收集参数:艺人列表 = [米津玄師, 緑黄色社会],沿用上次参数
2. **加载上次快照**:在 `snapshots/` 中找到 `kenshi_yonezu_ryokushaka_20260408.json`,加载为 `previousSnapshot`
3. 使用 Task 工具并行搜索两位艺人的最新巡演信息
4. 执行组合匹配,输出最新方案
5. **保存本次快照**:写入 `kenshi_yonezu_ryokushaka_20260415.json`
6. **执行 diff**:对比上次 15 场 vs 本次 17 场
- 发现:🆕 新增 3 场(绿黄色社会追加了福冈、札幌两场 + 米津玄師新增上海场)
- 发现:❌ 取消 1 场(米津玄師 12/10 名古屋场)
- 发现:🎫 售票状态变更 2 场(米津玄師 12/3 仙台 "预售"→"在售"、12/4 仙台 "预售"→"在售")
7. 在方案输出末尾附上变更总结,特别提示"仙台场已开售,建议尽快购票"
FILE:output-template.md
# 输出格式模板
在对话中输出清晰的文字总结,使用以下格式:
```
## 多艺人追星出行方案
> 搜索艺人:{艺人A}、{艺人B}、{艺人C}
> 时间范围:{范围}
> 组合容忍天数:{N} 天
---
### 方案 1 ⭐ 最佳覆盖(覆盖 {N}/{总数} 位艺人)
📍 目的地:{城市/都市圈}
📅 行程:{起始日期} — {结束日期}(共 {N} 天)
**演出安排:**
| # | 日期 | 艺人 | 场馆 | 票价 | 状态 |
|---|------|------|------|------|------|
| 1 | {日期} | {艺人A} | {场馆} | {价格} | {状态} |
| 2 | {日期} | {艺人B} | {场馆} | {价格} | {状态} |
🔗 购票链接:
- {艺人A}:{链接}
- {艺人B}:{链接}
<!-- 以下机票部分仅在用户开启机票搜索时展示 -->
✈️ 机票(飞猪实时报价):
{航空公司} {航班号} | {出发机场}→{到达机场} | {直达/中转}
去程:{出发时间} → {到达时间}({飞行时长})
回程:{出发时间} → {到达时间}({飞行时长})
往返价格:¥{价格}
🔗 购票:{飞猪链接}
🚄 城际交通(如有):{方式} | {起点}→{终点} | ¥{价格}
<!-- 机票部分结束 -->
<!-- 以下酒店部分仅在用户开启酒店搜索时展示 -->
🏨 酒店推荐(飞猪实时报价):
| 酒店 | 档次 | 每晚价格 | 位置 | 预订 |
|------|------|----------|------|------|
| {酒店名称} | {星级/档次} | {价格} | {附近地标} | [预订链接]({飞猪链接}) |
| {酒店名称} | {星级/档次} | {价格} | {附近地标} | [预订链接]({飞猪链接}) |
| ... | | | | |
住宿费用估算:{每晚价格} × {N} 晚 = ¥{总住宿费}(以最低价酒店计算)
<!-- 酒店部分结束 -->
💰 总花费估算(如有机票+酒店数据):
门票:¥{门票总计}
机票:¥{机票价格}
住宿:¥{住宿费用}({N} 晚)
城际交通:¥{交通费用}(如有)
**合计:约 ¥{总计}**
---
### 方案 2 ⭐ 最紧凑
...
---
### 未能组合的场次
以下场次在时间或地点上无法与其他艺人组合,单独列出供参考:
| 艺人 | 日期 | 城市 | 场馆 | 备注 |
|------|------|------|------|------|
| {艺人C} | {日期} | {城市} | {场馆} | 该时段无其他艺人在附近演出 |
```
## 场次变更总结(Diff)
当存在上次搜索快照时,在主方案输出之后附上变更总结。格式如下:
```
---
## 场次变更总结
> 对比上次搜索:{上次搜索日期}({N} 天前)
> 本次搜索:{本次日期}
### 概览
| 变更类型 | 数量 |
|----------|------|
| 🆕 新增场次 | {N} |
| ❌ 取消/下架 | {N} |
| 🎫 售票状态变更 | {N} |
| 🏟️ 场馆变更 | {N} |
| 💰 票价变更 | {N} |
| ✅ 未变化 | {N} |
<!-- 仅展示有变更的分类,数量为 0 的可省略 -->
### 🆕 新增场次
| 艺人 | 日期 | 城市 | 场馆 | 票价 | 状态 |
|------|------|------|------|------|------|
| {艺人} | {日期} | {城市} | {场馆} | {票价} | {状态} |
### ❌ 取消/下架场次
| 艺人 | 日期 | 城市 | 场馆 | 说明 |
|------|------|------|------|------|
| {艺人} | {日期} | {城市} | {场馆} | 上次搜索存在,本次未找到 |
### 🎫 售票状态变更(需关注)
| 艺人 | 日期 | 城市 | 上次状态 | → | 本次状态 |
|------|------|------|----------|---|----------|
| {艺人} | {日期} | {城市} | 在售 | → | 售罄 |
<!-- 「在售→售罄」和「预售→在售」是最需要用户关注的变更,加粗或额外提醒 -->
### 🏟️ 场馆变更
| 艺人 | 日期 | 城市 | 上次场馆 | → | 本次场馆 |
|------|------|------|----------|---|----------|
| {艺人} | {日期} | {城市} | {旧场馆} | → | {新场馆} |
### 💰 票价变更
| 艺人 | 日期 | 城市 | 上次票价 | → | 本次票价 |
|------|------|------|----------|---|----------|
| {艺人} | {日期} | {城市} | {旧价格} | → | {新价格} |
```
**首次搜索时(无上次快照)的提示:**
```
---
> 📸 已保存本次搜索快照({N} 场演出),下次搜索相同艺人时将自动显示场次变更。
```
## 特殊场景处理
### 场景 1:某个艺人完全没有找到演出信息
告知用户该艺人暂无公开的巡演计划,建议关注其官方社交媒体,并继续为其他艺人生成组合方案。
### 场景 2:所有艺人都有演出,但没有找到任何可组合的方案
- 列出每位艺人各自最值得去的场次
- 说明无法组合的原因(时间差距太大 / 地区完全不同)
- 建议放宽容忍天数或地区限制,询问用户是否要调整参数重试
### 场景 3:组合中包含音乐节
如果某个艺人的演出是在音乐节上(如 Summer Sonic、Coachella),标注该场次属于音乐节,提醒用户需要购买的是音乐节通票而非单场门票,并检查同一音乐节是否还有用户列表中的其他艺人出演——如果有,这将是一个高价值组合。
FILE:diff-tracking.md
# 场次变更追踪(Diff Tracking)
每次搜索完成后,将本次搜索结果保存为快照文件。下次运行时自动加载上次快照并与本次结果做 diff,在输出末尾生成「场次变更总结」。
## 快照存储
### 文件位置
快照存储在 skill 目录下的 `snapshots/` 子目录中:
```
~/.qoderwork/skills/multi-concert-trip-planner/snapshots/
├── {快照ID}.json ← 每次搜索的结果快照
└── latest.json ← 符号链接,指向最新快照(便于快速读取上次结果)
```
### 快照 ID 命名规则
快照 ID = `{艺人列表排序后用下划线连接}_{搜索日期YYYYMMDD}`
示例:`ado_yoasobi_20260408.json`
如果同一天对相同艺人列表搜索多次,后次覆盖前次。
### 快照 JSON 结构
```json
{
"snapshotId": "kenshi_yonezu_ryokushaka_backnumber_20260408",
"createdAt": "2026-04-08T15:30:00+08:00",
"artists": ["米津玄師", "緑黄色社会", "back number"],
"timeWindow": "2026 下半年",
"shows": [
{
"id": "kenshi_yonezu_20261203_sendai",
"artist": "米津玄師",
"date": "2026-12-03",
"time": "18:00",
"venue": "セキスイハイムスーパーアリーナ",
"city": "仙台",
"country": "日本",
"price": "¥9,800",
"ticketStatus": "在售",
"ticketUrl": "https://...",
"source": "WebSearch snippet"
}
],
"totalShows": 15,
"searchDuration": "约 3 分钟"
}
```
**场次 ID 生成规则:** `{艺人名拼音/英文小写}_{日期YYYYMMDD}_{城市拼音小写}`,用于跨快照匹配同一场演出。
## 工作流程
### 搜索前:加载上次快照
1. 根据当前搜索的艺人列表,在 `snapshots/` 中查找最近一次匹配的快照
2. 匹配逻辑:艺人列表排序后完全相同(忽略大小写和空格)
3. 如果找到匹配快照,加载为 `previousSnapshot`
4. 如果没有找到(首次搜索该艺人组合),跳过 diff,搜索结束后直接保存快照
**查找命令:**
```bash
ls -t ~/.qoderwork/skills/multi-concert-trip-planner/snapshots/{艺人列表快照ID前缀}*.json | head -1
```
### 搜索后:保存快照 + 执行 diff
1. 将本次搜索的所有场次整理为快照 JSON 格式
2. 写入 `snapshots/{快照ID}.json`
3. 更新 `latest.json` 指向新快照
4. 如果存在 `previousSnapshot`,执行 diff 算法
**保存命令示例:**
```bash
# 确保 snapshots 目录存在
mkdir -p ~/.qoderwork/skills/multi-concert-trip-planner/snapshots
# 写入快照文件(通过 Write 工具)
# 更新 latest.json 符号链接
ln -sf {快照ID}.json ~/.qoderwork/skills/multi-concert-trip-planner/snapshots/latest.json
```
## Diff 算法
### 匹配规则
两条场次记录被视为"同一场演出"需满足:
- **艺人相同**(忽略大小写)
- **日期相同**(精确到天)
- **城市相同**(忽略"市"/"City"后缀,如"仙台" = "仙台市")
不依赖场次 ID 做精确匹配,因为场馆名可能在不同数据源中表述不同。
### 变更分类
对比 `previousSnapshot.shows` 和当前 `currentShows`,产出 5 类变更:
| 变更类型 | 判定逻辑 | 图标 |
|----------|----------|------|
| **新增场次** | 当前有、上次无(按艺人+日期+城市匹配不到) | 🆕 |
| **取消/下架场次** | 上次有、当前无 | ❌ |
| **场馆变更** | 同一场演出但场馆名称不同 | 🏟️ |
| **售票状态变更** | 同一场演出但售票状态变化(如"预售"→"在售"、"在售"→"售罄") | 🎫 |
| **票价变更** | 同一场演出但票价区间发生变化 | 💰 |
**优先级排序:** 取消 > 新增 > 售票状态变更 > 场馆变更 > 票价变更
### 不变场次
如果某场演出在两次快照中完全一致(日期、城市、场馆、售票状态、票价均未变),归入"不变",不在 diff 中展示。
## 输出格式
→ 详见 `output-template.md`「场次变更总结」部分
Diff 总结在主方案输出之后、末尾展示。包含:
- 上次搜索时间
- 各类变更的汇总数字
- 按变更类型分组的详细变更列表
- 需要用户关注的重点变更(如"在售→售罄"需要紧急关注)
## 注意事项
- 快照仅记录场次信息,不记录机票/酒店数据(这些实时数据每次搜索都不同,不适合做 diff)
- 如果两次搜索的时间窗口不同(如上次搜"下半年",这次搜"10-12月"),diff 时只比较两次时间窗口的交集部分,避免因搜索范围缩小而产生大量虚假"取消"
- 如果用户增减了艺人列表(如上次搜 A+B,这次搜 A+B+C),新增艺人的场次全部标为"新增",其余艺人正常做 diff
- 快照文件较小(通常 <10KB),无需定期清理。如需手动清理:`ls ~/.qoderwork/skills/multi-concert-trip-planner/snapshots/`
- 首次搜索某组艺人时,无 diff 输出,仅保存快照并提示"已保存本次搜索快照,下次搜索相同艺人时将自动显示场次变更"
FILE:combination-matching.md
# 智能组合匹配算法
**单艺人模式:** 如果用户只输入了 1 个艺人,跳过本步骤,直接进入机票/酒店搜索(如开启)或输出呈现。
**多艺人模式:** 将所有艺人的有效演出场次汇总后,执行组合匹配算法。
## 1. 定义"可组合"条件
两场(或多场)演出被视为"可组合"需满足:
1. **时间相近**:演出日期之间的间隔 ≤ 用户设定的容忍天数(默认 7 天)
2. **地点相近**:满足以下任一条件:
- **同城市**:在同一个城市(如都在东京)
- **同都市圈**:在已知的相邻城市群内(如东京-横滨、大阪-神户-京都、纽约-新泽西、伦敦-伯明翰等)
- **同国家且交通便利**:在同一国家内,高铁/飞机 3 小时内可达
3. **不同艺人**:每个组合必须包含至少 2 个不同艺人的演出
## 2. 组合生成策略
1. 将所有演出按日期排序,形成一个时间线
2. 用滑动窗口(窗口大小 = 容忍天数)在时间线上扫描
3. 对窗口内的演出,按城市/都市圈分组
4. 在每个地理分组中,检查是否包含 ≥ 2 个不同艺人
5. 如果是,生成一个组合候选
## 3. 组合评分
对每个候选组合计算综合评分,权重如下:
| 因素 | 权重 | 说明 |
|------|------|------|
| 艺人覆盖数 | 40% | 覆盖的艺人越多得分越高;覆盖全部艺人为满分 |
| 时间紧凑度 | 25% | 演出之间间隔天数越少越好 |
| 地理集中度 | 20% | 同城 > 同都市圈 > 同国家跨城 |
| 售票可行性 | 15% | 全部在售 > 部分预售 > 包含售罄场次 |
### 3.1 地区降权系数
在基础评分之上,对特定地区的场次施加降权系数(乘法修正)。降权不影响场次的搜索和展示,仅影响组合排序优先级。
| 地区 | 降权系数 | 说明 |
|------|----------|------|
| 台湾(台北、新北、桃园、高雄、台南等) | **×0.6** | 大陆用户前往台湾需办理通行证和签注,手续周期较长、存在不确定性,出行便利性低于大陆及免签/落地签目的地 |
**计算规则:**
1. 先按上方四维权重计算基础综合评分 `baseScore`
2. 检查组合中所有场次的举办地区
3. 如果组合中**任一场次**位于降权地区,最终评分 = `baseScore × 该地区降权系数`
4. 如果组合中涉及**多个不同降权地区**,取最低的降权系数
5. 不在降权表中的地区系数为 1.0(无影响)
**示例:**
- 组合 A(北京+温州):baseScore 85 × 1.0 = **85**
- 组合 B(北京+台北):baseScore 90 × 0.6 = **54** → 排在组合 A 之后
> **维护说明:** 降权系数可根据实际出行便利性调整。如未来台湾自由行政策放宽,可适当提高系数(如 0.8)。如需对其他地区降权(如需要复杂签证的国家),在表中追加即可。
按综合评分从高到低排序,取前 **10 个** 组合进入下一步。
## 4. 常见都市圈参考表
在判断"地点相近"时,参考以下都市圈映射:
**日本:**
| 都市圈 | 包含城市 |
|--------|----------|
| 东京圈 | 东京、横滨、埼玉、千叶、川崎 |
| 关西圈 | 大阪、京都、神户、奈良 |
| 名古屋圈 | 名古屋、丰田 |
| 福冈圈 | 福冈、北九州 |
**韩国:**
| 都市圈 | 包含城市 |
|--------|----------|
| 首尔圈 | 首尔、仁川、京畿道、高阳、水原 |
| 釜山圈 | 釜山、蔚山 |
**中国大陆:**
| 都市圈 | 包含城市 |
|--------|----------|
| 长三角 | 上海、杭州、南京、苏州、无锡 |
| 大湾区 | 深圳、广州、东莞、佛山 |
| 京津冀 | 北京、天津 |
| 成渝 | 成都、重庆 |
**港澳台:**
| 都市圈 | 包含城市 |
|--------|----------|
| 港澳 | 香港、澳门 |
| 台湾北部 | 台北、新北、桃园 |
| 台湾南部 | 高雄、台南 |
**北美:**
| 都市圈 | 包含城市 |
|--------|----------|
| 纽约圈 | 纽约、新泽西、布鲁克林、长岛、纽瓦克 |
| 洛杉矶圈 | 洛杉矶、安纳海姆、英格尔伍德、帕萨迪纳 |
| 旧金山湾区 | 旧金山、奥克兰、圣何塞 |
| 芝加哥圈 | 芝加哥、罗斯蒙特 |
| 多伦多圈 | 多伦多、密西沙加、汉密尔顿 |
**欧洲:**
| 都市圈 | 包含城市 |
|--------|----------|
| 伦敦圈 | 伦敦、温布利、克罗伊登 |
| 巴黎圈 | 巴黎、圣但尼、楠泰尔 |
| 莱茵-鲁尔 | 杜塞尔多夫、科隆、多特蒙德、埃森 |
| 兰斯塔德 | 阿姆斯特丹、鹿特丹、乌得勒支 |
**东南亚:**
| 都市圈 | 包含城市 |
|--------|----------|
| 新加坡 | 新加坡 |
| 曼谷圈 | 曼谷、暖武里 |
| 雅加达圈 | 雅加达、茂物 |
| 马尼拉圈 | 马尼拉、奎松、帕赛 |
遇到不在表中的城市时,用常识判断是否属于同一都市圈或交通便利区域。
FILE:hotel-search.md
# 酒店搜索(flyai)
**本步骤仅在用户明确要求搜索酒店时执行。**
对排名前列的组合方案,使用 `flyai` CLI 工具(飞猪旅行)搜索演出城市的酒店。该工具返回实时价格和预订链接。
## 日期策略
- 入住日期:第一场演出的前一天(与机票去程日期一致)
- 退房日期:最后一场演出的后一天(与机票回程日期一致)
- 如果组合中涉及多个城市,为每个城市分别搜索酒店,入住/退房日期按该城市的演出安排确定
## 搜索命令
```bash
flyai search-hotel \
--dest-name {目的地城市} \
--key-words {场馆名称或地标} \
--check-in-date {入住日期 YYYY-MM-DD} \
--check-out-date {退房日期 YYYY-MM-DD} \
--sort price_asc
```
参数说明:
- `--dest-name` 目的地城市名称,如"东京"、"大阪"、"首尔"
- `--key-words` 使用场馆名称作为关键词,确保搜索到场馆附近的酒店(如"东京巨蛋"、"大阪城ホール")
- `--sort price_asc` 按价格从低到高排序(默认推荐,也可根据用户需要改为 `distance_asc`/`rate_desc`)
- 如有星级要求,加 `--hotel-stars {1-5}`(逗号分隔,如 `--hotel-stars 4,5`)
- 如有每晚预算上限,加 `--max-price {金额}`
- 如有床型偏好,加 `--hotel-bed-types {king/twin/multi}`
- 如有酒店类型偏好,加 `--hotel-types {hotel/homestay/inn}`
**多个组合/城市的酒店搜索应并行执行。**
## 返回数据解析
flyai 返回 JSON 格式,从 `data.itemList` 数组中提取每个酒店的以下字段:
| 字段 | JSON 路径 | 说明 |
|------|-----------|------|
| 酒店名称 | `name` | 如 "东京巨蛋酒店" |
| 价格 | `price` | 每晚价格字符串(如 "¥1505") |
| 地址 | `address` | 酒店详细地址 |
| 星级/档次 | `star` | 如 "经济型"、"舒适型"、"高档型"、"豪华型" |
| 附近地标 | `interestsPoi` | 如 "近东京巨蛋"、"近秋叶原" |
| 预订链接 | `detailUrl` | 飞猪直达链接 |
| 装修时间 | `decorationTime` | 可选,如 "2023" |
| 品牌 | `brandName` | 可选,如 "万豪"、"希尔顿"(可能为 null) |
## 筛选与推荐策略
1. 每个城市取 **前 5 家** 酒店推荐(默认按价格排序)
2. 优先展示 `interestsPoi` 中包含场馆名称或附近地标的酒店
3. 按档次分层推荐:至少各展示 1 家经济型/舒适型和 1 家高档型/豪华型(如有),满足不同预算需求
4. 如用户指定了星级或预算,严格按条件过滤后再推荐
5. 计算总住宿费用时,使用每晚价格 × 入住晚数(从价格字符串中提取数字)
## 搜索补充策略
- 搜索酒店时优先使用场馆名称作为 `--key-words`,确保推荐的酒店距离场馆较近,方便观演
- 如果场馆关键词搜索结果较少,退而使用城市核心区域(如"新宿"、"涩谷"、"梅田")作为关键词补充搜索
- 酒店价格会波动(尤其是演唱会期间热门城市),提醒用户看到合适的酒店尽早预订
FILE:BLOCKED_SITES.md
# WebFetch 失败站点记录
以下网站在使用 WebFetch 抓取时无法获取有效内容。根据失败类型,采取不同降级策略:仅用 WebSearch 摘要,或使用 agent-browser 浏览器抓取。
> 最后测试时间:2026-04
## 超时(Timeout)— 仅用 WebSearch 摘要
| 站点 | URL 示例 | 失败原因 |
|------|----------|----------|
| lignea.co.jp | https://lignea.co.jp/ryokushaka/ | WebFetch 超时(>60s),2026-04 复测仍超时 |
| sonymusic.co.jp | https://www.sonymusic.co.jp/artist/ryokusyaka/info/581667 | WebFetch 超时(>60s)。注:同域名 `/live/` 路径属于 JS 渲染类型,见下方 agent-browser 一节 |
> 超时类站点不建议用 agent-browser(大概率加载也极慢),优先用 WebSearch 摘要。
## HTTP 错误 — 仅用 WebSearch 摘要
| 站点 | URL 示例 | 失败原因 |
|------|----------|----------|
| reissuerecords.net | https://reissuerecords.net/ | HTTP 403 Forbidden,服务器拒绝访问 |
> HTTP 403/5xx 类错误通常与 User-Agent 或 IP 限制有关,agent-browser 可能同样被拒绝,不建议尝试。
## JS 渲染站点 — 可用 agent-browser 🟢
以下站点 WebFetch 只能获取空壳 HTML,但 agent-browser 可以完整渲染 JS 内容。**当 WebSearch 摘要信息不足时,应使用 agent-browser 抓取。**
| 站点 | URL 示例 | WebFetch 表现 | agent-browser 抓取方式 |
|------|----------|---------------|----------------------|
| ryokushaka.com | https://www.ryokushaka.com/live/ | 仅导航栏和 banner | `open` → `wait 3000` → `snapshot -i` |
| ryokushaka.com | https://www.ryokushaka.com/news/archive/?581667 | 同上 | 同上 |
| sonymusic.co.jp | https://www.sonymusic.co.jp/artist/ryokusyaka/live/ | 仅导航链接 | `open` → `wait 3000` → `snapshot -i`(日程可能需要点击展开) |
| ticket.kenshiyonezu.jp | https://ticket.kenshiyonezu.jp/pages/2026_detail | 无演出信息 | `open` → `wait 3000` → `snapshot -i` |
**agent-browser 抓取模板:**
```bash
agent-browser open {URL}
agent-browser wait 3000
agent-browser snapshot -i
# 如需翻页或展开更多内容:
# agent-browser click @eN && agent-browser wait 1000 && agent-browser snapshot -i
agent-browser close
```
## 部分可用(需注意)
| 站点 | URL 示例 | 说明 |
|------|----------|------|
| livefans.jp | https://www.livefans.jp/groups/265804 | 此前返回 504,2026-04 复测部分页面已恢复(团体页面可用 WebFetch),但艺人详情页(/artists/)仍只返回导航链接 — 可尝试 agent-browser |
## 降级策略总览
```
信息需求
├─ WebSearch 摘要足够? → 直接提取,无需访问站点
├─ 需要补充详情?
│ ├─ 目标是可靠站点? → WebFetch(rockinon.com、natalie.mu 等)
│ ├─ 目标是 JS 渲染站点? → agent-browser(见上方 🟢 标记)
│ └─ 目标是超时/403 站点? → 放弃,仅用 WebSearch 摘要
└─ 所有手段都无数据? → 标记该艺人"暂无公开巡演信息"
```
**可靠的 WebFetch 站点:** rockinon.com、natalie.mu、fashion-press.net、tower.jp、news.yahoo.co.jp、backnumber.info
FILE:concert-search.md
# 演唱会信息搜索
对每个艺人,使用 `WebSearch` 搜索其演唱会和巡演信息。**为提升效率,多个艺人的搜索应通过 Task 工具并行执行。**
## 搜索策略(每个艺人至少 8 条查询)
### 基本查询(当年 + 明年各 4 条,中日韩英四语)
**必须同时搜索当年和明年两个年份。** 绝不能只搜当年就下结论"没有后续活动"。
```
"{艺人} ライブ ツアー {当年} 日程" ← 日文搜索,覆盖日本巡演
"{艺人} concert tour {当年} dates" ← 英文搜索,覆盖欧美巡演
"{艺人} 演唱会 巡演 {当年}" ← 中文搜索,覆盖中国大陆及华语圈
"{艺人} 콘서트 투어 {当年} 일정" ← 韩文搜索,覆盖韩国巡演
"{艺人} ライブ ツアー {明年} 日程" ← 覆盖明年日本巡演
"{艺人} concert tour {明年} dates" ← 覆盖明年欧美巡演
"{艺人} 演唱会 巡演 {明年}" ← 覆盖明年中国巡演
"{艺人} 콘서트 투어 {明年} 일정" ← 覆盖明年韩国巡演
```
仅用通用查询即可覆盖主流平台(Ticketmaster、Songkick、Bandsintown、Eventernote、大麦网、Interpark、Yes24 等结果会自然出现在搜索结果中),不再逐个平台做 `site:` 限定搜索。
### 追加查询:检查最近活动上的新发表
**重大新活动经常在刚结束的live/演唱会上官宣。** 如果搜索中发现该艺人近期(过去30天内)刚完成了一场演出或活动,必须额外搜索该活动是否公布了新情报:
```
"{艺人} {近期活动名} 新情報 発表" ← 查日文新闻
"{艺人} {近期活动名} announcement new" ← 查英文新闻
"{艺人} 追加公演 新ライブ 発表 {当年}" ← 通用追加公演查询
```
这一步不可跳过。漏掉"活动现场官宣的下一场"是最常见的搜索遗漏。
## 三层信息提取策略
信息提取遵循**逐层降级**原则,尽量用最轻量的方式获取数据:
### 第一层:WebSearch 摘要提取(默认,最快)
**优先从 `WebSearch` 返回的摘要片段(snippet)中直接提取日期、场馆、城市、票价等关键信息。** 大多数情况下摘要已包含足够的结构化数据,无需额外请求。
### 第二层:WebFetch 补充详情
仅在以下情况使用 `WebFetch` 补充详情:
- 摘要信息不完整(如缺少票价或售票状态)
- 且目标网站属于**已知可靠站点**(见下方列表)
**已知可靠的 WebFetch 站点(响应快、内容可抓取):**
- rockinon.com — 日本音乐媒体,巡演报道详细
- natalie.mu — 日本娱乐新闻,演出信息全
- fashion-press.net — 票价信息详细
- tower.jp — 巡演公告完整
- news.yahoo.co.jp — 聚合各媒体报道
- backnumber.info — 巡演日程完整
**禁止 WebFetch 的站点(参见 BLOCKED_SITES.md):**
- lignea.co.jp — 超时
- sonymusic.co.jp — 超时 / 内容为空
- reissuerecords.net — HTTP 403
- 以及其他在历史执行中记录到 BLOCKED_SITES.md 的站点
### 第三层:agent-browser 浏览器抓取(JS 渲染站点专用)
当目标站点依赖 JS 动态渲染(WebFetch 只能获取空壳 HTML),**且该站点是官方信息源或数据唯一来源**时,使用 `agent-browser` 启动真实浏览器抓取完整内容。
**适用场景(参见 BLOCKED_SITES.md「可用 agent-browser」标记):**
- 艺人/乐队官方网站的巡演页面(如 ryokushaka.com/live/、ticket.kenshiyonezu.jp)
- 唱片公司的演出日程页面(如 sonymusic.co.jp/artist/.../live/)
- JS 渲染的售票平台详情页
- WebFetch 仅返回导航栏/banner 的页面
**标准抓取流程:**
```bash
# 1. 打开目标页面并等待 JS 渲染完成
agent-browser open {URL}
agent-browser wait 3000
# 2. 获取页面快照(文字内容 + 交互元素)
agent-browser snapshot -i
# 3. 如果需要查看完整日程(可能需要点击展开/翻页)
agent-browser click @eN # 点击"更多日程"按钮
agent-browser wait 1000
agent-browser snapshot -i # 重新获取内容
# 4. 完成后关闭浏览器
agent-browser close
```
**使用原则:**
- agent-browser 是最重量级的手段,仅在第一层和第二层都无法获取关键信息时使用
- 每次抓取后及时 `agent-browser close` 释放资源
- 如果同一个 Task 中需要抓取多个 JS 站点,使用命名 session 隔离:`agent-browser --session {name} open {URL}`
- 抓取结果同样提取下方"提取字段"表中的 7 个标准字段
## 提取字段
| 字段 | 说明 |
|------|------|
| 艺人 | 表演者或乐队名称 |
| 日期与时间 | 演出日期和开始时间(含时区) |
| 场馆 | 场馆名称 |
| 城市与国家 | 演出所在城市和国家 |
| 票价 | 价格区间(注明货币) |
| 售票状态 | 在售 / 售罄 / 预售 / 候补 |
| 购票链接 | 直接链接 |
## 后处理
对每个艺人的结果去重并按日期排序,过滤掉**今天起 14 天内(含)的场次**(太临近的演出来不及准备机票和签证),仅保留半个月后及更远的场次。
FILE:flight-search.md
# 机票搜索(flyai)
**本步骤仅在用户明确要求搜索机票时执行。**
对排名前列的组合方案,使用 `flyai` CLI 工具(飞猪旅行)搜索从用户出发城市到演出城市的往返机票。该工具返回实时价格和购票链接,数据准确性远优于 WebSearch 摘要。
## 日期策略
默认策略为去程演出前一天、回程演出后一天,但可根据演出时间和航班到达时间做**当天出行优化**,节省一晚住宿费用。
### 去程日期判断
- **默认**:第一场演出的前一天
- **可优化为当天**:如果满足以下任一条件,去程改为首场演出当天:
- 演出开始时间较晚(18:00 及以后),且存在当天中午前(12:00 前)到达目的地的航班
- 演出开始时间为下午场(14:00-17:59),且存在当天上午(10:00 前)到达目的地的航班
- **优化时的搜索方式**:同时搜索前一天和当天两个去程日期,将两者的结果合并推荐,标注当天去程的航班需注意"时间较紧"
### 回程日期判断
- **默认**:最后一场演出的后一天
- **可优化为当天**:如果满足以下任一条件,回程改为末场演出当天:
- 演出结束时间较早(17:00 前结束),且存在当天晚间(20:00 后出发)的航班
- 演出为下午场且预计 16:00 前结束,存在当天 19:00 后出发的航班
- **优化时的搜索方式**:同时搜索当天和后一天两个回程日期,将两者的结果合并推荐,标注当天回程的航班需注意"散场后需尽快赶往机场"
### 注意事项
- 当天出行优化需要已知演出的具体开始时间,如果只有日期没有时间,不做优化,使用默认的前/后一天策略
- 即使做了当天优化,仍然保留前一天去程/后一天回程的搜索结果作为"稳妥方案"供用户选择
- 到达时间需考虑从机场到场馆的交通时间(一般预留 2-3 小时),回程需考虑从场馆到机场的交通时间(一般预留 1.5-2 小时)
- 如果组合中涉及同一国家的多个城市,只搜索主要入境城市的机票(如日本搜东京或大阪入境)
## 搜索命令
```bash
flyai search-flight \
--origin {出发城市} \
--destination {目的地城市} \
--dep-date {去程日期 YYYY-MM-DD} \
--back-date {回程日期 YYYY-MM-DD} \
--sort-type 3
```
参数说明:
- `--sort-type 3` 表示按价格从低到高排序,确保最便宜的结果排在前面
- 如需仅看直飞,加 `--journey-type 1`
- 如有预算限制,加 `--max-price {金额}`
**多个组合的机票搜索应并行执行(同时发出多条 flyai 命令)。**
## 返回数据解析
flyai 返回 JSON 格式,从 `data.itemList` 数组中提取每个选项的以下字段:
| 字段 | JSON 路径 | 说明 |
|------|-----------|------|
| 往返价格 | `ticketPrice` | 含税总价(CNY) |
| 航程类型 | `journeys[].journeyType` | "直达" 或 "中转" |
| 航班号 | `journeys[].segments[].marketingTransportNo` | 如 CA927 |
| 航空公司 | `journeys[].segments[].marketingTransportName` | 如 "国航" |
| 出发机场 | `journeys[].segments[].depStationName` | 如 "首都国际机场" |
| 到达机场 | `journeys[].segments[].arrStationName` | 如 "关西国际机场" |
| 出发时间 | `journeys[].segments[].depDateTime` | 如 "2026-10-22 08:40:00" |
| 到达时间 | `journeys[].segments[].arrDateTime` | 如 "2026-10-22 12:40:00" |
| 飞行时长 | `journeys[].totalDuration` | 分钟数 |
| 购票链接 | `jumpUrl` | 飞猪直达链接 |
## 筛选策略
- 每个组合取 **最便宜的 3 个** 机票选项(直飞优先展示,中转次之)
- 如果组合中涉及跨城市移动(如东京看完一场,再去大阪看另一场),额外搜索城市间交通方案(新干线、廉航等)并估算费用
- 搜索机票时考虑演出城市对应的主要机场(如东京对应 NRT/HND,伦敦对应 LHR/LGW/STN)
FILE:snapshots/latest.json
{
"snapshotId": "jay_chou_mayday_20260408",
"createdAt": "2026-04-08T16:00:00+08:00",
"artists": ["周杰伦", "五月天"],
"timeWindow": "不限时间",
"shows": [
{
"id": "jay_chou_20260515_wenzhou",
"artist": "周杰伦",
"date": "2026-05-15",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布(参考¥580-2,380)",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260516_wenzhou",
"artist": "周杰伦",
"date": "2026-05-16",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260517_wenzhou",
"artist": "周杰伦",
"date": "2026-05-17",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260626_beijing",
"artist": "周杰伦",
"date": "2026-06-26",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260627_beijing",
"artist": "周杰伦",
"date": "2026-06-27",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260628_beijing",
"artist": "周杰伦",
"date": "2026-06-28",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260717_sanya",
"artist": "周杰伦",
"date": "2026-07-17",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260718_sanya",
"artist": "周杰伦",
"date": "2026-07-18",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260719_sanya",
"artist": "周杰伦",
"date": "2026-07-19",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260801_nanjing",
"artist": "周杰伦",
"date": "2026-08-01",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260802_nanjing",
"artist": "周杰伦",
"date": "2026-08-02",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260803_nanjing",
"artist": "周杰伦",
"date": "2026-08-03",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20261017_melbourne",
"artist": "周杰伦",
"date": "2026-10-17",
"time": "19:30",
"venue": "Marvel Stadium, Docklands",
"city": "墨尔本",
"country": "澳大利亚",
"price": "AUD $208-$748",
"ticketStatus": "4/9公开发售",
"ticketUrl": "https://www.ticketmaster.com.au/jay-chou-carnival-ii-world-tour-in-melbourne-docklands-17-10-2026/event/2500647FEE7EB74F",
"source": "Ticketmaster AU"
},
{
"id": "jay_chou_20261121_sydney",
"artist": "周杰伦",
"date": "2026-11-21",
"time": "19:30",
"venue": "ENGIE Stadium, Sydney Showground",
"city": "悉尼",
"country": "澳大利亚",
"price": "未公布",
"ticketStatus": "待公布",
"ticketUrl": "https://www.sydneyshowground.com.au/whats-on/jay-chou-carnival--world-tour/",
"source": "Sydney Showground"
},
{
"id": "mayday_20260430_beijing",
"artist": "五月天",
"date": "2026-04-30",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260501_beijing",
"artist": "五月天",
"date": "2026-05-01",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260502_beijing",
"artist": "五月天",
"date": "2026-05-02",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260503_beijing",
"artist": "五月天",
"date": "2026-05-03",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260508_beijing",
"artist": "五月天",
"date": "2026-05-08",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260509_beijing",
"artist": "五月天",
"date": "2026-05-09",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260510_beijing",
"artist": "五月天",
"date": "2026-05-10",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260511_beijing",
"artist": "五月天",
"date": "2026-05-11",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260515_beijing",
"artist": "五月天",
"date": "2026-05-15",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260516_beijing",
"artist": "五月天",
"date": "2026-05-16",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260517_beijing",
"artist": "五月天",
"date": "2026-05-17",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260518_beijing",
"artist": "五月天",
"date": "2026-05-18",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260627_taipei",
"artist": "五月天",
"date": "2026-06-27",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260628_taipei",
"artist": "五月天",
"date": "2026-06-28",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260629_taipei",
"artist": "五月天",
"date": "2026-06-29",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260704_taipei",
"artist": "五月天",
"date": "2026-07-04",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260705_taipei",
"artist": "五月天",
"date": "2026-07-05",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260706_taipei",
"artist": "五月天",
"date": "2026-07-06",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260711_taipei",
"artist": "五月天",
"date": "2026-07-11",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260712_taipei",
"artist": "五月天",
"date": "2026-07-12",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
}
],
"totalShows": 34,
"searchDuration": "约 5 分钟"
}
FILE:snapshots/jay_chou_mayday_20260408.json
{
"snapshotId": "jay_chou_mayday_20260408",
"createdAt": "2026-04-08T16:00:00+08:00",
"artists": ["周杰伦", "五月天"],
"timeWindow": "不限时间",
"shows": [
{
"id": "jay_chou_20260515_wenzhou",
"artist": "周杰伦",
"date": "2026-05-15",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布(参考¥580-2,380)",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260516_wenzhou",
"artist": "周杰伦",
"date": "2026-05-16",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260517_wenzhou",
"artist": "周杰伦",
"date": "2026-05-17",
"time": "19:00",
"venue": "温州市奥体中心主体育场",
"city": "温州",
"country": "中国",
"price": "未公布",
"ticketStatus": "待开售",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260626_beijing",
"artist": "周杰伦",
"date": "2026-06-26",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260627_beijing",
"artist": "周杰伦",
"date": "2026-06-27",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260628_beijing",
"artist": "周杰伦",
"date": "2026-06-28",
"time": "未知",
"venue": "未官宣",
"city": "北京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet (网传未确认)"
},
{
"id": "jay_chou_20260717_sanya",
"artist": "周杰伦",
"date": "2026-07-17",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260718_sanya",
"artist": "周杰伦",
"date": "2026-07-18",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260719_sanya",
"artist": "周杰伦",
"date": "2026-07-19",
"time": "未知",
"venue": "未官宣",
"city": "三亚",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260801_nanjing",
"artist": "周杰伦",
"date": "2026-08-01",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260802_nanjing",
"artist": "周杰伦",
"date": "2026-08-02",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20260803_nanjing",
"artist": "周杰伦",
"date": "2026-08-03",
"time": "未知",
"venue": "未官宣",
"city": "南京",
"country": "中国",
"price": "未知",
"ticketStatus": "等待官宣",
"ticketUrl": "",
"source": "WebSearch snippet"
},
{
"id": "jay_chou_20261017_melbourne",
"artist": "周杰伦",
"date": "2026-10-17",
"time": "19:30",
"venue": "Marvel Stadium, Docklands",
"city": "墨尔本",
"country": "澳大利亚",
"price": "AUD $208-$748",
"ticketStatus": "4/9公开发售",
"ticketUrl": "https://www.ticketmaster.com.au/jay-chou-carnival-ii-world-tour-in-melbourne-docklands-17-10-2026/event/2500647FEE7EB74F",
"source": "Ticketmaster AU"
},
{
"id": "jay_chou_20261121_sydney",
"artist": "周杰伦",
"date": "2026-11-21",
"time": "19:30",
"venue": "ENGIE Stadium, Sydney Showground",
"city": "悉尼",
"country": "澳大利亚",
"price": "未公布",
"ticketStatus": "待公布",
"ticketUrl": "https://www.sydneyshowground.com.au/whats-on/jay-chou-carnival--world-tour/",
"source": "Sydney Showground"
},
{
"id": "mayday_20260430_beijing",
"artist": "五月天",
"date": "2026-04-30",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260501_beijing",
"artist": "五月天",
"date": "2026-05-01",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260502_beijing",
"artist": "五月天",
"date": "2026-05-02",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260503_beijing",
"artist": "五月天",
"date": "2026-05-03",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/10 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260508_beijing",
"artist": "五月天",
"date": "2026-05-08",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260509_beijing",
"artist": "五月天",
"date": "2026-05-09",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260510_beijing",
"artist": "五月天",
"date": "2026-05-10",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260511_beijing",
"artist": "五月天",
"date": "2026-05-11",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/14 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260515_beijing",
"artist": "五月天",
"date": "2026-05-15",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260516_beijing",
"artist": "五月天",
"date": "2026-05-16",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260517_beijing",
"artist": "五月天",
"date": "2026-05-17",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260518_beijing",
"artist": "五月天",
"date": "2026-05-18",
"time": "19:30",
"venue": "国家体育场(鸟巢)",
"city": "北京",
"country": "中国",
"price": "¥380/580/780/980/1,380",
"ticketStatus": "预售4/17 12:25",
"ticketUrl": "https://www.damai.cn",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260627_taipei",
"artist": "五月天",
"date": "2026-06-27",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260628_taipei",
"artist": "五月天",
"date": "2026-06-28",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260629_taipei",
"artist": "五月天",
"date": "2026-06-29",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260704_taipei",
"artist": "五月天",
"date": "2026-07-04",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260705_taipei",
"artist": "五月天",
"date": "2026-07-05",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260706_taipei",
"artist": "五月天",
"date": "2026-07-06",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260711_taipei",
"artist": "五月天",
"date": "2026-07-11",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
},
{
"id": "mayday_20260712_taipei",
"artist": "五月天",
"date": "2026-07-12",
"time": "19:30",
"venue": "台北大巨蛋",
"city": "台北",
"country": "中国台湾",
"price": "NT$1,525-5,525",
"ticketStatus": "5/3 11:00 开售",
"ticketUrl": "https://tixcraft.com",
"source": "WebSearch + WebFetch"
}
],
"totalShows": 34,
"searchDuration": "约 5 分钟"
}
Systematically test web application access controls for broken authorization vulnerabilities. Use this skill whenever: performing a penetration test or secur...
---
name: access-control-vulnerability-testing
description: |
Systematically test web application access controls for broken authorization vulnerabilities. Use this skill whenever: performing a penetration test or security assessment of a web application's authorization model; testing for vertical privilege escalation (low-privilege user accessing high-privilege functions); testing for horizontal privilege escalation (user accessing another user's data); auditing multistage workflows for mid-flow authorization bypasses; checking whether protected static files are directly accessible without authorization; testing whether HTTP method substitution (HEAD, arbitrary verbs) bypasses platform-level access rules; probing for insecure access control models based on client-submitted parameters (admin=true), HTTP Referer headers, or IP geolocation; enumerating hidden or unlisted application functionality; reviewing source code or HTTP traffic for missing server-side authorization checks; using Burp Suite's site map comparison feature to compare high-privilege and low-privilege user access; assessing server-side API endpoint authorization. Covers all six WAHH vulnerability categories: completely unprotected functionality, identifier-based access control (IDOR), multistage function bypasses, static file exposure, platform misconfiguration, and insecure client-controlled access models. Maps to OWASP Testing Guide (OTG-AUTHZ-*), CWE-284 (Improper Access Control), CWE-285 (Improper Authorization), CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-862 (Missing Authorization), CWE-863 (Incorrect Authorization), and OWASP Top 10 A01:2021 (Broken Access Control).
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/web-application-hackers-handbook/skills/access-control-vulnerability-testing
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
depends-on: []
source-books:
- id: web-application-hackers-handbook
title: "The Web Application Hacker's Handbook: Finding and Exploiting Security Flaws"
authors: ["Dafydd Stuttard", "Marcus Pinto"]
edition: 2
chapters: [8]
pages: "257-285"
tags: [access-control, authorization, privilege-escalation, idor, broken-access-control, burp-suite, http-methods, platform-misconfiguration, static-files, multistage-process, owasp, penetration-testing, appsec, cwe-284, cwe-285, cwe-639, cwe-862, cwe-863]
execution:
tier: 2
mode: hybrid
inputs:
- type: codebase
description: "Application source code, route definitions, middleware configuration, or deployment descriptors — primary for white-box review"
- type: document
description: "HTTP traffic captures, Burp Suite proxy logs, or prior application mapping output — primary for black-box testing"
tools-required: [Read, Grep, Write]
tools-optional: [Bash, WebFetch]
mcps-required: []
environment: "Run inside a project codebase for white-box review, or alongside HTTP traffic logs for black-box assessment. Authorized security testing context required."
discovery:
goal: "Identify all exploitable broken access control vulnerabilities across six vulnerability categories; produce a structured findings report with privilege escalation evidence, CWE mappings, severity ratings, and remediation recommendations"
tasks:
- "Map all authenticated and unauthenticated application surfaces, understanding which roles should access which functions and resources"
- "Test each of the six vulnerability categories using the prescribed multi-account and method-variation workflows"
- "Use Burp Suite site map comparison (high-privilege to low-privilege replay) to automate bulk coverage"
- "Test each multistage workflow step individually for isolated authorization checks"
- "Enumerate hidden functionality through client-side code review and content discovery"
- "Document findings with CWE mapping, privilege escalation impact, and evidence"
- "Produce countermeasures aligned with the centralized authorization model and multilayered privilege framework"
audience:
roles: ["penetration-tester", "application-security-engineer", "security-minded-developer", "security-architect", "bug-bounty-researcher"]
experience: "intermediate-to-advanced — assumes familiarity with HTTP, web proxies (Burp Suite or equivalent), and basic authentication and authorization concepts"
triggers:
- "Penetration test of a web application's authorization model"
- "Security code review of server-side access control logic"
- "Assessment of a multi-role application (admin, manager, regular user, guest)"
- "Audit of any application that segregates resources by user identity (documents, orders, accounts)"
- "Testing a workflow-driven application (checkout, account creation, approval flows)"
- "Detection of OWASP A01:2021 Broken Access Control findings"
---
# Access Control Vulnerability Testing
## When to Use
Use this skill when you need to determine whether a web application correctly enforces authorization decisions for every user, role, and request type. Access controls are the mechanism by which an application decides whether a given request is permitted to perform an action or access a resource. Broken access controls affect 71% of web applications and enable attackers to take full control of administrative functionality, access other users' sensitive data, or bypass business logic constraints.
This skill applies to authorized penetration tests, security code reviews, and appsec audits. It is not a substitute for legal authorization to test a target application.
---
## Core Concepts
### Three Access Control Categories
**Vertical access control** enforces separation between privilege levels (ordinary user vs. administrator). Vertical privilege escalation occurs when a lower-privilege user successfully accesses higher-privilege functions.
**Horizontal access control** enforces that users can only access their own resources (documents, orders, accounts). Horizontal privilege escalation occurs when a user accesses another user's resources.
**Context-dependent access control** enforces that users access application states only in the prescribed sequence. Business logic exploitation occurs when a user bypasses required workflow steps (for example, skipping the payment stage of a checkout flow).
Horizontal and vertical escalations frequently chain: discovering another user's document identifier may allow modifying that user's security role, converting horizontal access into vertical compromise.
### Six Vulnerability Categories
1. **Completely unprotected functionality** — Sensitive functions accessible to anyone who knows the URL; the only "protection" is UI-level link omission.
2. **Identifier-based functions** (Insecure Direct Object Reference / IDOR) — Authorization based solely on a resource identifier passed as a request parameter, with no server-side ownership check.
3. **Multistage function bypasses** — Authorization checked only at step 1 of a multi-request workflow; later steps assume legitimacy without re-verifying privilege.
4. **Static file exposure** — Protected content served as static files directly accessible by URL, bypassing all application-layer authorization.
5. **Platform misconfiguration** — Access rules defined at the web server or application server layer (URL path + HTTP method) that can be bypassed by substituting an alternative HTTP method or specifying an unrecognized method.
6. **Insecure access control methods** — Authorization decisions driven by client-controllable data: request parameters (`admin=true`), HTTP `Referer` header, or IP-based geolocation.
---
## Process
### Phase 1: Reconnaissance and Access Control Mapping
**Step 1: Understand the authorization model.**
Before probing, answer these questions from application mapping output or source code:
- Does the application segregate users into distinct roles with different functionality?
- Does any functionality give individual users access to a subset of resources of the same type (documents, orders, accounts)?
- Do administrators use the same application instance as regular users?
- Are there identifiers in URLs or POST bodies that signal which resource or function is being targeted?
- Are there parameters that appear to carry privilege flags (`admin`, `role`, `isAdmin`)?
WHY: Access control testing without understanding the intended authorization model produces false positives (expected differences flagged as vulnerabilities) and false negatives (violations that look like normal variance). The authorization model defines what "violation" means.
**Step 2: Identify all application surfaces.**
Using your proxy history and any content discovery output, catalog:
- All URLs and endpoints, noting which require authentication
- All functions that modify state (create, update, delete operations)
- All resource types with per-user ownership semantics
- All multi-step workflows (checkout, account creation, approval chains)
- All static file downloads (PDFs, spreadsheets, binaries)
- All client-side code (JavaScript, decompiled browser extension components) for hidden URLs or admin menu items
WHY: Poorly protected functionality often exists outside the normal navigation paths. JavaScript building role-conditional UI elements frequently references admin URLs that are not linked from ordinary user interfaces.
---
### Phase 2: Multi-Account Testing Workflow
This is the primary methodology. It requires at minimum two accounts: one high-privilege and one low-privilege.
**Step 1: Map the application as the high-privilege user.**
With Burp configured as your proxy (interception disabled), browse all application functionality using the high-privilege account. This builds a complete site map of all accessible endpoints.
WHY: You need to know what the high-privilege account can access before you can test whether the low-privilege account is incorrectly permitted to access it. Starting with the low-privilege account means you may never discover the privileged endpoints to test.
**Step 2: Use Burp's "Compare Site Maps" feature.**
In Burp's Target tab, right-click the site map and select "compare site maps." Configure the second site map to re-request all items from the first site map using the low-privilege session (via a recorded login macro or a specific session cookie). Burp will highlight added, removed, and modified responses between the two maps, including a diff count for modified items.
WHY: Manual comparison of dozens or hundreds of endpoints is error-prone and slow. Automated replay eliminates the mechanical work while preserving human judgment for interpreting results — two identical responses to an admin function indicate a violation; two different responses to a personal profile page are expected and benign.
**Step 3: Interpret comparison results with context.**
Identical responses do not always indicate a vulnerability (a search function returning the same results is harmless). Different responses do not always indicate correct enforcement (an admin function returning different content each visit may still be accessible). Apply judgment for each flagged item.
**Step 4: Test horizontal access control with two same-privilege accounts.**
Identify resources owned by Account A (document IDs, order numbers, account references). From Account B's session, request those same resource identifiers directly — either by URL or by replaying the POST parameters. Access to Account A's resource from Account B's session is a horizontal privilege escalation.
WHY: The Burp site map comparison approach tests vertical access control effectively. Horizontal escalation requires explicit cross-account resource substitution because both accounts see the same set of endpoints.
---
### Phase 3: Testing by Vulnerability Category
#### Category 1: Completely Unprotected Functionality
1. Review all JavaScript in the application for conditional UI construction based on role flags. Extract any admin URLs referenced in conditionally-rendered code.
2. Review HTML comments for references to unlisted endpoints.
3. Request admin/management URLs directly from a low-privilege or unauthenticated session.
4. If the application uses direct access to server-side API methods, test for additional undiscovered methods using similar naming conventions (`getBalance` → `getAllBalances`, `getCurrentUserRoles` → `getAllUserRoles`, `listInterfaces`, `getAllUsersInRoles`).
WHY: Security through obscurity is not access control. URLs appear in browser history, server logs, proxy logs, and bookmarks. URL knowledge cannot be revoked when a user changes roles. Any function reachable by knowing its URL without a server-side authorization check is unprotected, regardless of whether the URL is published.
#### Category 2: Identifier-Based Functions (IDOR)
1. Identify all request parameters that reference resources: document IDs, account numbers, order references, user IDs.
2. Determine whether identifiers are sequential (integers), partially predictable, or cryptographically random (GUIDs).
3. For sequential identifiers: substitute your own identifier with adjacent values or values observed in application logs and error messages.
4. For non-sequential identifiers: test the ones you already possess from your own account activity. Even non-guessable identifiers expose a vulnerability if the server fails to verify ownership.
5. If you can generate multiple identifiers rapidly (by creating documents or orders), analyze the sequence for predictability patterns using session token analysis techniques.
6. If access controls are confirmed broken and identifiers are predictable, document the automated harvest risk.
WHY: Resource identifiers are not secrets. They appear in server logs, are transmitted via clients, and may be observed from within the application itself (logs, audit trails). The server must verify that the requesting user is authorized to access the specific resource identified, regardless of how the identifier was obtained.
#### Category 3: Multistage Function Bypasses
1. Walk through the complete protected workflow as the high-privilege user, noting every HTTP request in sequence (including redirects, form submissions, and parameterless confirmation requests).
2. Re-execute each individual request in the sequence from a low-privilege session. Do not assume that step 3 is protected because step 1 is protected — test each step independently.
3. Use Burp's "request in browser in current browser session" feature to replay each high-privilege request within a low-privilege browser session. Paste the Burp-provided URL into the low-privilege browser and observe whether the action succeeds.
4. Identify any stage where the application passes data validated at an earlier step as a client-side parameter to a later step (hidden fields, query string values). Test whether modifying those parameters at the final stage allows bypassing the earlier validation.
WHY: Developers commonly validate authorization at the entry point of a workflow and assume that any user who reaches later stages must have passed the earlier checks. This assumption is violated whenever an attacker can directly submit a request to a later-stage endpoint. Each step must independently verify that the current session is authorized to perform the action, not just that it reached this step via a valid earlier step.
#### Category 4: Static File Exposure
1. Complete the legitimate process for accessing a protected static resource (purchase, login, privilege grant) and capture the final download URL.
2. Using a different session (low-privilege or unauthenticated), request that URL directly.
3. If direct access succeeds, analyze the URL naming scheme for the full resource set. Sequential or patterned names (ISBNs, sequential IDs) allow bulk enumeration.
WHY: Static files served directly from the web root bypass all application-layer code. No server-side script runs to verify the requester's authorization. The only protection available is web-server-level authentication or serving files indirectly through a dynamic page that implements authorization logic.
#### Category 5: Platform Misconfiguration (HTTP Method Bypass)
1. Using the high-privilege account, identify sensitive state-changing requests (create user, change role, delete record).
2. If the request does not include anti-Cross-Site Request Forgery tokens or similar protections, attempt to re-issue it using alternative HTTP methods: substitute `POST` with `GET`, then `HEAD`, then an arbitrary invalid method (e.g., `JEFF`).
3. If the application honors any alternative method and performs the action, test that method's access controls using a low-privilege account.
WHY: Platform-level access rules (web server or application server configuration) often deny specific HTTP methods but allow others. `HEAD` requests are typically handled by the same code as `GET`, so if `GET` performs a sensitive action, `HEAD` may too. Some platforms route unrecognized HTTP methods to the `GET` handler, allowing arbitrary method names to bypass deny rules that only enumerate specific blocked methods.
#### Category 6: Insecure Access Control Methods
**Parameter-based access control:**
1. As a high-privilege user, observe whether any requests contain parameters indicating privilege level (`admin=true`, `role=admin`, `isManager=1`).
2. As a low-privilege user, add or modify these parameters to claim elevated privilege.
3. Where application pages show different functionality to different roles, try appending privilege parameters to the URL query string and POST body.
**Referer-based access control:**
1. Identify functions you are legitimately authorized to access.
2. Remove or modify the `Referer` header on those requests. If access fails, the application is using `Referer` as an authorization signal.
3. For functions you are not authorized to access, forge a `Referer` value matching the administrative page that would legitimately precede the request.
**Location-based access control:**
1. If the application enforces geographic restrictions, test bypass via a web proxy, VPN, or data-roaming mobile device in the permitted location.
2. Test direct manipulation of any client-side geolocation mechanisms.
WHY: Any access control decision based on data the client can control is fundamentally insecure. Request parameters, `Referer` headers, and IP geolocation are all attacker-controllable. Authorization decisions must be driven exclusively from server-side session state, which the attacker cannot forge.
---
### Phase 4: Testing with Limited Account Access
When only one account is available:
1. Use content discovery techniques to enumerate functionality not linked from the normal interface. Low-privilege browsing is often sufficient to both enumerate and directly access unlisted administrative functionality.
2. Review all client-side HTML and scripts for references to hidden functionality or script-driven UI elements.
3. Decompile any browser extension components to discover references to server-side endpoints.
4. Test for `Referer`-based access control as described above.
5. Probe for parameter-based privilege escalation by appending common privilege parameters to requests.
---
### Phase 5: Documentation
For each confirmed finding, record:
- **Vulnerability category** (from the six-category taxonomy above)
- **CWE identifier** (CWE-862 for missing authorization, CWE-639 for IDOR, CWE-863 for incorrect authorization, CWE-284 for general broken access control)
- **Affected endpoint(s)** with full request detail
- **Proof of exploitation**: what was accessed or performed, from which account, with what evidence (response body, diff count, HTTP status)
- **Privilege escalation type**: vertical, horizontal, or business logic
- **Severity**: consider data sensitivity, actions permitted, and chainability to further compromise
- **Countermeasure** (see Securing Access Controls section)
---
## Securing Access Controls
Use these principles when documenting remediation recommendations or reviewing defensive implementations:
**Avoid the common pitfalls:**
- Do not rely on URL or resource identifier secrecy as a substitute for authorization. Assume every URL and identifier is known to every user.
- Do not trust client-submitted parameters to indicate privilege (`admin=true`). All access control decisions must derive from server-side session state.
- Do not assume that because a user cannot reach page B from page A, they cannot request page B directly.
- Do not transmit validated data via the client between workflow stages without revalidating it on receipt at each stage.
**Implement a centralized authorization model:**
- Document access control requirements for every unit of functionality: who can use it and what resources they can access via it.
- Implement a single central application component responsible for all access control decisions.
- Route every request through this component before any functional code executes.
- Use programmatic enforcement (every page must call the central component) to prevent omissions — make it impossible to ship a page that lacks an authorization check.
**Apply a multilayered privilege model (defense in depth):**
- Application layer: session-driven authorization via a central component.
- Application server layer: URL-path and HTTP-method rules using a default-deny model (deny anything not explicitly permitted).
- Database layer: separate database accounts per user role with least-privilege grants; read-only accounts for read-only operations.
- Operating system layer: application components run under least-privilege OS accounts.
**Protect static content** by either: (a) serving files through a dynamic handler that performs authorization before streaming the file, or (b) using HTTP authentication or application-server access controls to wrap direct file requests.
**For high-sensitivity functions** (bill payee creation, privilege changes): implement per-transaction reauthentication or dual authorization to mitigate both access control bypass and session hijacking impact.
**Log all access to sensitive data and all sensitive actions** to enable detection and investigation of access control breaches.
---
## Examples
### Example 1: Vertical Privilege Escalation via Unprotected Admin URL
**Scenario:** E-commerce platform with separate admin and customer roles. Penetration test with one admin account and one customer account.
**Trigger:** Burp site map comparison shows admin account visited `/admin/users/list` and `/admin/users/new`. Low-privilege replay returns HTTP 200 for both with the same response body as the admin.
**Process:**
1. Browsed application as admin; site map captured all admin endpoints.
2. Configured Burp to re-request the site map using the customer session cookie.
3. Compared site maps: `/admin/users/list` showed diff count 0 (identical responses).
4. Confirmed: customer session receives the full user list including credential data.
5. Tested `/admin/users/new` POST with customer session — new admin account created successfully.
**Output:** Critical finding — CWE-862 (Missing Authorization). Completely unprotected admin functionality. Recommended: central authorization component checks session role before any admin handler executes.
---
### Example 2: Horizontal Privilege Escalation via IDOR
**Scenario:** Document management application. User A and User B both have standard accounts. Authorized test with both accounts.
**Trigger:** After logging in as User A, the document list shows URLs in the form `/ViewDocument.php?docid=1280149120`. Login as User B and browse to User B's own document at `docid=1280149125`.
**Process:**
1. While authenticated as User B, modified `docid` parameter to `1280149120` (User A's document ID).
2. Application returned User A's document in full without any authorization error.
3. Sequentially tested adjacent document IDs; all returned documents belonging to other users.
4. Confirmed identifiers are sequential integers — enumerable with Burp Intruder.
**Output:** High finding — CWE-639 (Authorization Bypass Through User-Controlled Key). Server does not verify that the requesting session owns the referenced document. Recommended: on every document request, verify that the authenticated user's ID matches the document's owner field before returning content.
---
### Example 3: Multistage Bypass and HTTP Method Substitution
**Scenario:** SaaS application with an "Add User" admin workflow (3 steps: choose role, enter details, confirm). Single admin account available; one regular-user account.
**Trigger:** Application mapping reveals the workflow spans three POST requests: `/admin/newuser/step1`, `/admin/newuser/step2`, `/admin/newuser/step3`. Step 1 returns 403 for the regular-user session. Steps 2 and 3 have not been tested independently.
**Process:**
1. As admin, walked through the complete workflow; captured all three POST requests in Burp.
2. Using Burp "request in browser in current browser session," replayed step 2 and step 3 requests inside the regular-user browser session.
3. Step 2 returned the details form with HTTP 200. Step 3 accepted the submission and confirmed user creation.
4. Confirmed that only step 1 checks authorization; steps 2 and 3 assume legitimacy.
5. Additionally tested step 3 with HTTP method changed from POST to HEAD — server executed the creation action (inferred from subsequent user list check) while returning no response body.
**Output:** Critical finding — CWE-285 (Improper Authorization) on steps 2 and 3; CWE-284 on HTTP method bypass. Recommended: each step independently verifies the session role; platform rules use default-deny for all HTTP methods except those explicitly permitted for each endpoint.
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — The Web Application Hacker's Handbook: Finding and Exploiting Security Flaws by Dafydd Stuttard, Marcus Pinto.
## Related BookForge Skills
This skill is standalone. Browse more BookForge skills: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
Guide startup growth strategy by diagnosing which phase the startup is in (Phase I: making something people want, Phase II: marketing something people want,...
---
name: startup-traction-strategy-by-phase
description: "Guide startup growth strategy by diagnosing which phase the startup is in (Phase I: making something people want, Phase II: marketing something people want, Phase III: scaling) and selecting phase-appropriate traction channels. Use whenever a startup founder, growth marketer, or product leader is deciding how to split time between product and traction, asking whether they have product-market fit, choosing which channels fit their current stage, dealing with rising CAC or saturating channels, wondering if they should pivot, applying the 50% Rule, or escaping the Product Trap ('if we build it they will come'). Activates on phrases like 'product-market fit', 'phase I', 'phase II', 'scaling', 'growth strategy', 'should we pivot', '50% rule', 'product trap', 'traction vs product', 'which channels for our stage', 'moving the needle'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/startup-traction-strategy-by-phase
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [4]
domain: startup-growth
tags: [startup-growth, growth-strategy, startup-phases, product-market-fit, marketing-strategy]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Startup state — metrics, team size, product maturity, current traction activities"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for phase diagnosis and channel strategy documents"
discovery:
goal: "Diagnose the startup's current phase and produce a phase-appropriate traction strategy"
tasks:
- "Diagnose current phase (I/II/III) from observable signals"
- "Audit current time allocation against the 50% Rule"
- "Map phase-appropriate channels and filter out mismatched ones"
- "Apply the moving-the-needle filter to proposed activities"
- "Detect the Product Trap and phase-channel mismatch anti-patterns"
audience:
roles: [startup-founder, growth-marketer, head-of-marketing]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "User is unsure which phase their startup is in"
- "User's current channel is producing diminishing returns"
- "User asks whether to pivot"
- "User is spending all time on product and wondering about growth"
prerequisites: []
not_for:
- "User has not yet built a product"
- "User just wants to pick a channel (use bullseye-channel-selection)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 12
iterations_needed: 0
---
# Startup Traction Strategy by Phase
## When to Use
The startup is somewhere on the growth curve and needs a phase-appropriate traction strategy. Use this skill when:
- The founder can't tell if they have product-market fit yet
- Growth has plateaued and the channels that worked before aren't working now
- The founder is spending 90%+ of their time on product
- A pivot is being considered
- The user asks "what should we focus on for growth right now?"
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Current metrics:** users, revenue, growth rate (even rough)
→ Check prompt for: numeric counts, percentages, trends
→ If missing, ask: "What are your current metrics? Rough numbers are fine — users, paying customers, monthly growth."
- **Time allocation:** how the founder/team is currently splitting effort
→ Check prompt for: "spending X% on", "we focus on", "most of our time"
→ If missing, ask: "Roughly how is your week split between product work and getting customers?"
- **Current traction activities:** what's actively being tried
→ Check prompt for: "we do X for growth", channel names
→ If missing, ask: "What are you doing right now to get new customers?"
### Observable Context
- **Product maturity:** MVP, v1, v2+
- **Team size and composition**
- **How customers currently describe the product** (satisfaction signals)
### Default Assumptions
- If user count is under 1,000 and no clear growth rate exists → assume Phase I
- If rough product-market fit signals exist (paying customers, word-of-mouth, retention) → Phase II
- If established business model with consistent growth → Phase III
### Sufficiency Threshold
```
SUFFICIENT: metrics + time allocation + current activities known
PROCEED WITH DEFAULTS: metrics known; assume time is 90/10 product/traction (the common failure mode)
MUST ASK: metrics are completely unknown (can't diagnose phase)
```
## Process
Use TodoWrite:
- [ ] Step 1: Diagnose phase
- [ ] Step 2: Audit time allocation against 50% Rule
- [ ] Step 3: Map phase-appropriate channels
- [ ] Step 4: Apply the moving-the-needle filter
- [ ] Step 5: Produce phase strategy document
### Step 1: Diagnose Phase (I / II / III)
**ACTION:** Classify the startup into one of three phases based on observable signals:
- **Phase I — Making something people want.** No product-market fit yet. Signals: low user count, high churn, constant product revision, customers don't obviously stick. The core job is building a product worth marketing.
- **Phase II — Marketing something people want.** Product-market fit established. Signals: customers stick, grow by word of mouth, revenue or engagement climbs. The core job is building a sustainable customer-acquisition engine.
- **Phase III — Scaling the business.** Business model established, market position significant. Signals: consistent growth rate, unit economics work, the question is how to dominate the market. The core job is scaling proven channels.
Write the diagnosis with one paragraph of evidence to `phase-diagnosis.md`.
**WHY:** Every downstream decision depends on phase. A Phase I startup doing Phase III tactics (mass advertising, PR campaigns, full sales teams) wastes money on channels that can't compound without a sticky product. A Phase III startup doing Phase I tactics (personal outreach, hand-holding each customer) underuses scale. Phase mismatch is the most common strategy error.
**IF** signals are mixed between Phase I and II → default to the earlier phase. The cost of over-investing in traction before fit is higher than the cost of under-investing briefly after fit.
### Step 2: Audit Time Allocation Against the 50% Rule
**ACTION:** Calculate how the founder/team is actually splitting time between product work and traction work. Compare to the 50% Rule: **50% of time on product, 50% on traction — at all times, in parallel, regardless of phase.**
If the split is 90/10 product/traction (the common default), name it explicitly. Quote the Product Trap warning: the #1 reason investors pass on otherwise-good founders is focus on product to the exclusion of everything else.
**WHY:** Most founders wildly over-invest in product. Marc Andreessen: "Almost every failed startup has a product. What failed startups don't have are enough customers." The Product Trap is the belief that "if we build it, they will come." Without explicit time-budget accountability, traction work gets crowded out by product work that always feels more urgent. The 50% Rule is a forcing function, not a guideline.
**IF** the user resists 50/50 because "the product isn't ready" → that's exactly when you need traction experiments, because channel feedback shapes the product.
**IF** the user is 50/50 already → excellent, skip to Step 3.
### Step 3: Map Phase-Appropriate Channels
**ACTION:** Based on the diagnosed phase, list which channels typically work and which typically don't. Use the mapping in [references/phase-channel-fit.md](references/phase-channel-fit.md).
Flag any current channel that's mismatched with the phase. Common mismatches:
- Phase I startup running SEM ads without product-market fit → burning budget on churning users
- Phase II startup still relying only on personal outreach → hitting volume ceiling
- Phase III startup ignoring PR → missing biggest growth lever
**WHY:** Channels have phase fit. "Some traction channels will move the needle early on but fail to work later. Others are hard to get working in Phase I but are major sources of traction in the later phases." Running a Phase I playbook in Phase II means growth stalls. Running a Phase III playbook in Phase I means spending on customers you can't retain. Matching phase to channel is the core of the book's strategy advice.
### Step 4: Apply the Moving-the-Needle Filter
**ACTION:** For each proposed or current traction activity, ask: "Can this plausibly deliver enough new customers to meaningfully advance our traction goal at our current scale?"
Do a back-of-envelope calculation: (target new customers) ÷ (realistic conversion rate, 1-5%) = audience you need to reach. Compare that to the channel's realistic reach. If the math doesn't work, the activity is off the needle.
Phase I needle ≠ Phase III needle:
- In Phase I, a tweet from a respected person or a speech to 300 people *can* move the needle.
- In Phase III, if you have 10,000 visitors/day, a blog post that sends 200 visitors is noise.
**WHY:** Founders waste time on activities that feel productive but can't meaningfully affect growth. The moving-the-needle filter is a math check: does the channel even have the volume to matter? Running a Facebook ad with $100 budget in Phase III is not a test — it's rounding error.
**IF** an activity can't pass the needle filter → cut it. Put the time back into the 50% traction budget.
### Step 5: Produce the Phase Strategy Document
**ACTION:** Write `phase-strategy.md` containing:
1. **Phase diagnosis** with evidence
2. **Current time allocation** vs 50% Rule (and the correction needed)
3. **Phase-appropriate channels** — which to pursue, which to cut
4. **Moving-the-needle audit** — activities cut, activities kept
5. **Next 4 weeks of traction experiments**, sized to the phase
**WHY:** A written strategy is a forcing function for accountability. "We're Phase I and the 50% Rule says we need more unscalable outreach" is easier to hold the team to than a verbal agreement. The document also makes phase transitions legible — in 3 months, re-read it and ask "what phase are we in now?"
## Inputs
- Startup metrics (users, revenue, growth rate)
- Current time allocation (product vs traction)
- Current traction activities
- Traction goal (if user has one)
## Outputs
Three markdown files:
1. **`phase-diagnosis.md`** — Phase (I/II/III) with evidence
2. **`phase-strategy.md`** — Complete strategy with time allocation correction and channel map
3. **`weekly-traction-plan.md`** — Next 4 weeks of phase-appropriate experiments
## Key Principles
- **Phase determines everything.** A channel that's a hit in Phase II can be a disaster in Phase I. WHY: The same tactic at the wrong time is a waste. Speed and volume needs change dramatically across phases — Phase I rewards unscalable tactics, Phase III punishes them.
- **50/50 is non-negotiable.** Not 80/20 in favor of product "because we're early". Not 20/80 "because we need customers fast". Always 50/50. WHY: Product and traction co-evolve. Traction experiments reveal what customers actually want. Product changes shape what traction channels work. Decoupling them is how startups die with "a great product nobody wanted."
- **The Product Trap has a specific detection signal.** If the founder says "the product isn't ready for marketing yet", that's the trap. WHY: The product is never "ready." Marc Andreessen: "The number one reason we pass on entrepreneurs is focusing on product to the exclusion of everything else." Ready for marketing means ready for feedback, not ready for perfection.
- **Re-diagnose phase quarterly.** Phases aren't permanent. What was Phase I six months ago might be Phase II now. WHY: Phase transitions are easy to miss from the inside. The channels that served you in Phase I will saturate as you enter Phase II. If you don't re-diagnose, you'll keep running Phase I tactics and watch growth flatten.
- **Unscalable tactics are a Phase I *strategy*, not a failure mode.** Paul Graham's "do things that don't scale" is phase-specific advice. In Phase I, it's correct. In Phase III, it's a trap. WHY: The same advice applied in the wrong phase produces opposite outcomes. Don't let "unscalable = bad" reflexes push you to premature scaling in Phase I.
## Examples
**Scenario: "We're 3 months in, 200 users, growth has stalled"**
Trigger: "Built a note-taking app for lawyers. 200 users in 3 months, mostly from Twitter. Growth has stalled the last 4 weeks. Only I'm doing marketing; 2 engineers on product."
Process: (1) Diagnose Phase I — low user count, no repeat customer signals, team still iterating product. (2) Time audit: founder estimates 70% product, 30% traction → flag the gap. Apply 50% Rule → founder needs to reclaim 20% of product time for traction. (3) Phase-appropriate channels: unscalable tactics work best here — targeting blogs (legal industry blogs), speaking at small legal conferences, direct outreach to named lawyers. Cut: any paid ads (wrong phase), no SEO (too slow for Phase I). (4) Moving-the-needle filter: founder was about to run $500 Facebook ads — kill that. $500 goes to sponsoring a legal-industry newsletter instead. (5) Produce 4-week plan: 10 cold emails/week to named lawyers, 1 guest post on a legal blog, outreach to 2 legal podcast hosts.
Output: Clear Phase I diagnosis, Product Trap flagged (70/30 instead of 50/50), and a concrete unscalable-first plan.
**Scenario: "Great growth for 18 months, now slowing"**
Trigger: "B2B SaaS, $200k MRR, 30% YoY growth. Content marketing drove most of our growth. Last 3 months growth has flattened to 5%. What's happening?"
Process: (1) Diagnose: likely Phase II → Phase III transition. Product-market fit clearly there. Content marketing is saturating (the Law of Shitty Click-Throughs). (2) Time audit: 50/50 seems maintained — that's good. (3) Phase-appropriate channels: Phase III should leverage channels with bigger volume ceilings. Consider PR (first big feature), paid ads at scale, BD with integration partners. (4) Moving-the-needle filter: a new blog post that sends 500 visitors no longer moves the needle at this scale. (5) Produce plan: kick off PR push (3 pitches to industry media), add SEM for bottom-funnel keywords, negotiate 2 integration partnerships.
Output: Phase II→III transition identified; next-phase channels selected; content remains but isn't the growth engine anymore.
**Scenario: The classic Product Trap**
Trigger: "We've been building for 8 months, launching soon, want to plan a big marketing push for launch day."
Process: (1) Diagnose Phase I — not launched, no customers. (2) Time audit: user says "we haven't done marketing yet because the product isn't ready" → Product Trap diagnosis, quote Andreessen. (3) 50% Rule applied retroactively — what traction experiments should have been running for the last 8 months? At minimum: building an email list, talking to 20 prospective customers weekly, finding 10 blogs where the audience lives. (4) Moving-the-needle: a "big launch day push" without a list or audience is a guaranteed flop. (5) Strategy: delay launch by 4 weeks, spend those weeks building traction groundwork (email list, blog relationships, 20 customer conversations), so launch lands on an audience that already cares.
Output: Product Trap named and corrected; launch plan now has traction preamble; founder understands the rule going forward.
## References
- For the full phase-channel fit mapping, see [references/phase-channel-fit.md](references/phase-channel-fit.md)
- For signs of each phase and transition signals, see [references/phase-signals.md](references/phase-signals.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select specific channels within your phase strategy
- `clawhub install bookforge-traction-channel-testing` — Run cheap tests on the channels you pick
- `clawhub install bookforge-startup-critical-path-planning` — Set quantified traction goals by phase
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/phase-channel-fit.md
# Phase-Channel Fit Map
Which channels typically work in which phase. Use as a starting point — every startup is different, but this captures the patterns from the book.
## Phase I: Making Something People Want
**Goal:** Find product-market fit. Small, highly-engaged customer base.
**Channels that typically work:**
- **Targeting Blogs** — Mid-level niche blogs give Phase I startups an audience without needing scale.
- **Sales (direct/enterprise)** — Personal outreach is expected and necessary. First customers come from relationships.
- **Speaking Engagements** — Small talks in front of the right audience (200 engaged people > 20k unengaged).
- **Community Building** — Seed a community of early believers who become co-creators.
- **Engineering as Marketing** — Free tools that solve one specific problem for one specific audience.
- **Business Development (focused)** — One strategic partnership can define the early story.
**Channels that typically don't work:**
- **SEM at scale** — Paid ads to churning users burn budget.
- **Offline Ads** — No scale to justify cost.
- **Trade Shows (big ones)** — Cost doesn't match the small audience they can actually reach.
## Phase II: Marketing Something People Want
**Goal:** Build a scalable customer-acquisition engine. Growth from repeatable channels.
**Channels that typically work:**
- **Content Marketing** — Compounds over time. Phase II is where the returns kick in.
- **SEO** — If you invested in Phase I, ranks now.
- **SEM** — Unit economics work because product-market fit gives you retention.
- **Email Marketing** — Lifecycle emails convert the audience built in Phase I.
- **Viral Marketing** — Only valuable if baked into the product early.
- **Affiliate Programs** — Need product-market fit so affiliates are willing to promote.
**Channels that may stop working:**
- **Personal outreach** — Hit volume ceiling. Can't scale with 2 founders.
- **Small targeted blogs** — Audience exhausted.
## Phase III: Scaling the Business
**Goal:** Dominate the market. Compound across multiple channels.
**Channels that typically work:**
- **Public Relations (PR)** — Feature stories drive the biggest single-day spikes.
- **Content Marketing (at scale)** — Publication-level content operations.
- **SEM (big budgets)** — Unit economics clear, just buy more.
- **Offline Ads** — TV/radio make sense at this scale.
- **Existing Platforms** — Day-1 presence on new platforms.
- **Trade Shows (major)** — Mass meetups with qualified buyers.
- **Speaking Engagements (marquee)** — Keynotes, not small meetups.
**Channels that typically can't keep up:**
- **Any Phase I unscalable tactic** — The math stops working.
## Source
Chapter 3 ("Traction Thinking") of *Traction* by Gabriel Weinberg and Justin Mares.
FILE:references/phase-signals.md
# Phase Signals and Transition Markers
How to tell which phase a startup is actually in, and when it's transitioning.
## Phase I Signals
- User count < 1,000 (soft threshold, varies by product)
- Product still actively being rewritten based on each user conversation
- Customers churn quickly (retention weak)
- Founder can name every customer
- Growth is bumpy and unpredictable
- "Traction goal" would be something like "first 100 paying customers"
## Phase I → II Transition
- Customers start sticking without prompting
- Word-of-mouth begins ("my friend told me about this")
- Founder stops needing to hand-hold every new customer
- Product stops being rewritten at the fundamental level
- Growth rate becomes more predictable month-over-month
## Phase II Signals
- Product-market fit is clear in retention data
- A channel is producing consistent leads
- Team is hiring to scale marketing/sales functions
- Traction goal is something like "reach break-even revenue" or "100k users"
- The question is "how do we grow the channel" not "do we have a channel"
## Phase II → III Transition
- The channel that worked starts to saturate (rising CAC, diminishing volume)
- Growth rate from the primary channel flattens
- Team has resources to pursue multiple channels in parallel
- Market is now aware of the company
## Phase III Signals
- Established business model with known unit economics
- Multiple channels contributing meaningfully
- Growth rate is more about scaling than discovery
- Traction goal is about market share or dominance
- Strategic concerns (competition, category definition) matter more than tactical channel selection
## Common Misdiagnoses
- **Phase I looking like Phase II:** Founder thinks they have product-market fit because a few customers love the product. Check retention: do customers come back, or were those a one-time spike?
- **Phase II looking like Phase III:** Founder thinks they're scaling because revenue grew, but the channel is actually saturating — they just haven't noticed CAC climbing.
- **Phase III looking like Phase I:** Founder acts like a scrappy startup at $10M ARR, refusing to hire scaled marketing. The unscalable tactics that got them here aren't enough anymore.
## Source
Chapter 3 ("Traction Thinking") of *Traction* by Gabriel Weinberg and Justin Mares.
Design startup sales processes using SPIN Selling, A/B/C lead tiering, PNAME qualification, and sales funnel design. Use whenever a founder or sales lead is...
---
name: startup-sales-process
description: "Design startup sales processes using SPIN Selling, A/B/C lead tiering, PNAME qualification, and sales funnel design. Use whenever a founder or sales lead is building a sales process, prioritizing leads, qualifying prospects, structuring sales calls, designing a sales funnel, dealing with enterprise deals, avoiding the Technology Tourist or False Change Agent traps, or transitioning from founder-led sales to a scaled sales team. Activates on phrases like 'sales process', 'sales funnel', 'SPIN selling', 'lead qualification', 'BANT', 'PNAME', 'enterprise sales', 'B2B sales', 'sales call structure', 'closing deals', 'pipeline management', 'sales methodology'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/startup-sales-process
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [19]
domain: startup-growth
tags: [startup-growth, sales, b2b-sales, sales-funnel, enterprise-sales]
depends-on: [bullseye-channel-selection]
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Target customer profile, product details, deal sizes, current sales activity"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for sales process docs and pipeline tracker"
discovery:
goal: "Design a sales process with funnel stages, SPIN conversation structure, and lead prioritization"
tasks:
- "Design the sales funnel stages (generate → qualify → close)"
- "Apply A/B/C lead tiering with time allocation"
- "Structure sales conversations using SPIN (Situation, Problem, Implication, Need-payoff)"
- "Qualify prospects using PNAME (Process, Need, Authority, Money, Estimated timing)"
- "Detect Technology Tourist and False Change Agent traps"
- "Reduce funnel blockage with specific tactics"
audience:
roles: [startup-founder, sales-lead, business-development]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "Founder doing sales for the first time"
- "Startup transitioning from founder sales to team sales"
- "Lead pipeline is mismanaged"
- "Sales calls don't convert"
- "Enterprise deals are getting stuck"
prerequisites:
- skill: bullseye-channel-selection
why: "Sales should be selected via Bullseye based on product/price fit"
not_for:
- "Consumer products that close via self-serve (use content/email instead)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 13
iterations_needed: 0
---
# Startup Sales Process
## When to Use
The startup needs a sales process — either designing one from scratch or fixing one that isn't working. Sales is typically right for:
- Enterprise or high-price products ($10k+ deals)
- Products requiring consultation before purchase
- B2B with specific decision-makers
- Complex/configurable products
Sales is typically wrong for:
- Low-price consumer products
- Self-serve SaaS under $100/month
- Products with instant-use value
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Product and price point:** what you sell, for how much
→ Check prompt for: product, pricing, tier
→ If missing, ask: "What does your product do, and what's the price point? Enterprise deals, SMB, mid-market?"
- **Current sales state:** who's doing sales, how many deals, win rate
→ Check prompt for: "I do all sales", "hired 2 reps", numbers
→ If missing, ask: "Who's currently doing sales, and what's the rough pipeline state?"
### Observable Context
- **Target customer profile:** industry, size, title of buyer
- **Existing sales assets:** decks, scripts, CRM
### Default Assumptions
- First-customer phase: founder does sales, 20-30 conversations to find 1 buyer
- A/B/C time allocation: 66-75% on A deals, rest on B, zero on C
- SPIN Selling conversation structure
- Deal size floor: $10k enterprise / $250/month SMB for sales to be economical
### Sufficiency Threshold
```
SUFFICIENT: product + price + current state known
PROCEED WITH DEFAULTS: product + price known, assume founder doing sales
MUST ASK: product/price is unknown
```
## Process
Use TodoWrite:
- [ ] Step 1: Design the funnel stages
- [ ] Step 2: Apply A/B/C lead tiering
- [ ] Step 3: Structure sales calls with SPIN
- [ ] Step 4: Qualify with PNAME
- [ ] Step 5: Detect wrong-first-customer traps
- [ ] Step 6: Reduce funnel blockage
### Step 1: Design the Funnel Stages
**ACTION:** Design a 3-stage funnel:
1. **Generate leads** — via other traction channels (SEO, SEM, content, targeting blogs). Cold email/calling is for first customers only; after that, leads should come from scalable channels.
2. **Qualify leads** — apply A/B/C tiering (Step 2) and PNAME qualification (Step 4) to decide where to spend time.
3. **Close leads** — structured conversations using SPIN (Step 3), with timeline commitment and specific deliverables.
Write the funnel to `sales-funnel.md` with stage definitions, handoff criteria, and time budgets.
**WHY:** Unstructured sales wastes time. Without explicit stages, reps work every lead equally, spending time on deals that will never close. The funnel structure is the basic hygiene that makes everything else possible.
### Step 2: Apply A/B/C Lead Tiering
**ACTION:** Classify every lead into one of three buckets:
- **A deals** — realistic close within 3 months. Receive **66-75% of sales rep time.** High-conviction, urgent buyer, clear budget.
- **B deals** — forecast close 3-12 months. Receive the remaining time. Build pipeline for the future.
- **C deals** — unlikely to close within 12 months. **Zero sales time.** Hand back to marketing for nurture.
Write current pipeline classification to `pipeline-tiers.md`.
**WHY:** Time is the scarcest sales resource. Without explicit tiering, reps spend time on C deals that feel interesting but won't close. The 66-75% / rest / zero allocation is a forcing function that produces more closed deals per unit time. Seller time on C deals is the single biggest source of wasted sales effort.
**IF** most deals are C → return to Bullseye. Sales may not be the right channel, or leads may be unqualified.
### Step 3: Structure Sales Calls with SPIN
**ACTION:** Use Neil Rackham's SPIN framework for structured conversations:
- **S — Situation:** 1-2 questions maximum. Establish buying context ("How's your team structured? What are you currently using?"). Over-using Situation questions signals unpreparedness and reduces close rates.
- **P — Problem:** Identify pain points ("What's frustrating about your current approach?"). Use sparingly — quickly define the problem then move on.
- **I — Implication:** Expand perceived problem magnitude ("How does this affect productivity? How many people are affected? What's the cost of not solving this?"). **This is the most important step** — it builds urgency.
- **N — Need-payoff:** Shift attention to the solution's benefits ("How would solving this help you? Whose work improves?"). Get the buyer to articulate the value themselves.
Based on Rackham's research across 35,000 sales calls.
Write scripts/questions per SPIN stage to `spin-questions.md`.
**WHY:** Most sales calls skip directly from Situation to a product pitch, missing the Problem-Implication-Need cycle that builds urgency. SPIN is the framework that makes the buyer talk themselves into the purchase, rather than the seller pushing them. Rackham's research showed it increased close rates meaningfully across 35,000 calls.
### Step 4: Qualify Prospects with PNAME
**ACTION:** Before investing sales time in any deal, check the 5 PNAME factors:
- **P — Process:** How does this company buy solutions? (Procurement process, approval chain)
- **N — Need:** How badly do they need a solution? Is the pain acute or abstract?
- **A — Authority:** Who has purchase authority? Is the person you're talking to the decision-maker?
- **M — Money:** Do they have budget? What does not solving it cost them?
- **E — Estimated timing:** What are budget and decision timelines?
If any factor is missing, the deal is likely C-tier.
**WHY:** Deals fall through because one of the 5 factors was wrong — no authority, no budget, no urgency. Catching missing factors upfront saves weeks of wasted sales time. PNAME is a specific pre-close checklist that forces clarity.
### Step 5: Detect Wrong-First-Customer Traps
**ACTION:** Two specific traps from Sean Murphy:
**Technology Tourist:** Prospect invites you in but has no interest in buying. They want to learn about the technology for intellectual or professional curiosity. Signal: they ask deep product questions but never discuss implementation or budget. Test question: "Have you brought other technology into your company?" — if the answer is "No, this would be our first," proceed cautiously.
**False Change Agent:** Someone claims to be a change agent who will drive your product through the org. Reality: they have no authority or organizational credibility. Signal: they oversell their influence ("I can make this happen"). Test: "How long have you been here? Have you implemented similar things before?" A 6-month-tenure person cannot be a change agent.
**WHY:** Both traps waste months of sales effort. The prospect looks real — meetings happen, demos happen, emails get returned — but the deal never closes because the underlying conditions don't exist. Naming the traps makes them detectable. Founders who don't know the patterns get burned repeatedly.
### Step 6: Reduce Funnel Blockage
**ACTION:** Common blockages and specific tactics:
- **IT install friction** → offer SaaS/cloud version
- **Risk aversion** → free trials, reference customers, case studies
- **Budget approval** → ROI calculators, business case templates
- **Competitive comparison** → competitive battle cards, comparison sheets
- **Long procurement** → channel partners, reseller agreements
- **Price resistance** → low intro price (<$250/mo SMB, <$10k enterprise floor)
- **Need clarification** → detailed FAQs, demo videos
Write blockage-specific tactics to `funnel-blockage-plan.md`.
**WHY:** Each blockage has a specific fix. Generic "sales training" doesn't solve specific blockages. Identifying which blockage is costing the most deals (by postmortem on lost deals) and applying the specific fix produces measurable improvement.
## Inputs
- Product and price point
- Target customer profile
- Current sales state (pipeline, team, metrics)
## Outputs
Five markdown files:
1. **`sales-funnel.md`** — 3-stage funnel with handoff criteria
2. **`pipeline-tiers.md`** — A/B/C classification of current deals
3. **`spin-questions.md`** — Prepared SPIN questions per call type
4. **`pname-checklist.md`** — PNAME qualification applied to top deals
5. **`funnel-blockage-plan.md`** — Specific blockage tactics
## Key Principles
- **A deals get 66-75% of time. C deals get zero.** WHY: Time is scarce. Misallocating it to C deals is the single biggest sales productivity drain. The explicit percentage is a forcing function.
- **SPIN > traditional pitches.** The buyer must articulate the value themselves. WHY: Buyers rationalize away seller claims; they don't rationalize away their own. SPIN makes the buyer do the persuasion.
- **Implication is the most important SPIN stage.** This is where urgency is built. WHY: Without Implication questions, the problem stays abstract and the deal stays in "interested but not urgent." Implication escalates the perceived cost of inaction.
- **Never skip PNAME on enterprise deals.** All 5 factors must be present. WHY: Deals with missing factors feel real but don't close. Catching the missing factor upfront saves weeks or months.
- **Name the wrong-first-customer traps.** Technology Tourist and False Change Agent are specific, detectable patterns. WHY: Unnamed patterns repeat indefinitely. Named patterns can be matched against and flagged.
- **Founder sales is for first 10-20 customers only.** After that, it's a data-gathering exercise that should hand off to hired reps or channels. WHY: Founder sales doesn't scale and founder time has higher-leverage uses. The handoff point is when you know what works well enough to script it.
## Examples
**Scenario: Founder on first enterprise deal**
Trigger: "Our first enterprise prospect is asking for a 30-minute call. They work at a Fortune 500. I've never done sales. What do I do?"
Process: (1) Run PNAME before the call — Process unknown, Need unclear, Authority unclear, Money unknown, Timing unknown. All 5 need answers. (2) SPIN structure: plan 2 Situation questions, 3 Problem questions, 4 Implication questions, 3 Need-payoff questions. (3) Detect traps: ask "have you brought other technology into your company?" and "how long have you been at the company?" (4) End of call: commit to specific deliverable with specific timeline ("If I ship X in 2 weeks, will you commit to a 30-day pilot?"). Get a yes/no.
Output: Call prep doc with PNAME questions, SPIN question list, trap detection script, and specific close question.
**Scenario: Pipeline is full of C deals**
Trigger: "We have 50 deals in our pipeline but only close 2 per quarter. What's wrong?"
Process: (1) A/B/C classify all 50. Likely result: 5 A, 10 B, 35 C. (2) 35 C deals have been consuming sales time with no payoff. Move them all to "marketing nurture" — zero sales time. (3) Reallocate saved time to the 5 A deals. (4) PNAME each A deal to confirm all 5 factors present; if not, downgrade to B. (5) Apply SPIN to next A-deal calls, especially Implication questions to build urgency.
Output: Pipeline restructuring with dramatic time reallocation to A deals.
**Scenario: Technology Tourist trap**
Trigger: "We've been in discussions with a Fortune 500 for 4 months. They keep asking for detailed demos but never move forward. What do I do?"
Process: (1) Classic Technology Tourist pattern. Test: ask "Have you brought similar technology into your company before?" If no → likely tourist. (2) Also ask: "What's your timeline for making a decision?" If vague → more tourist signals. (3) Apply time budget: this deal is C. Reallocate sales time. Leave the door open with a marketing nurture sequence. (4) Use the conversation as a data source for the product — tourists ask real questions, they just don't buy. (5) Move on.
Output: Tourist identification, graceful disengagement plan, reallocation to real deals.
## References
- For full SPIN question templates and PNAME qualification sheet, see [references/sales-templates.md](references/sales-templates.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select Sales as a channel deliberately
- `clawhub install bookforge-business-development-pipeline` — BD vs Sales distinction
- `clawhub install bookforge-startup-traction-strategy-by-phase` — Sales is Phase I first-customer tactic
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/sales-templates.md
# Sales Templates
SPIN question templates and PNAME qualification sheet from Chapter 18 of *Traction*.
## SPIN Question Templates
### Situation Questions (2 max)
Keep these short. Over-using them signals unpreparedness.
- "How is your [relevant process] currently set up?"
- "What tools are you using today for [category]?"
- "How many people on your team handle [relevant task]?"
### Problem Questions
Use sparingly — goal is to surface the specific pain.
- "What's frustrating about your current approach to [category]?"
- "Where does [current process] break down for you?"
- "What problems are you trying to solve that aren't being addressed today?"
- "Which parts of [relevant area] take more time than they should?"
### Implication Questions (MOST IMPORTANT)
This is where urgency is built. Expand the perceived cost of inaction.
- "How does this problem affect your team's productivity?"
- "How many hours per week does your team lose to [problem]?"
- "What's the downstream impact on [related metric]?"
- "Has this caused any issues with [customers / deadlines / growth]?"
- "If you don't solve this, what happens in 6 months?"
- "What does the status quo cost your company in [money / time / opportunity]?"
### Need-Payoff Questions
Get the buyer to articulate the value themselves.
- "How would solving [problem] help your team?"
- "What would it be worth to recover those [hours / dollars / deals]?"
- "If [outcome] were automated, what would your team focus on instead?"
- "Who else in the company benefits if this problem is solved?"
- "How would this help you hit your [quarterly/annual] goals?"
## PNAME Qualification Sheet
Complete this for every A and B deal.
```markdown
## [Company Name] — PNAME Qualification
### P — Process
- How does this company buy software?
- What's the procurement/approval chain?
- Is there a formal RFP process?
- [ ] Clear [ ] Unclear
### N — Need
- What specific pain are they trying to solve?
- How acute is the pain on a 1-5 scale?
- What happens if they don't solve it?
- [ ] Acute (4-5) [ ] Moderate (3) [ ] Weak (1-2)
### A — Authority
- Is the person I'm talking to the decision-maker?
- If not, who is, and when can I talk to them?
- Are there hidden stakeholders (IT, Security, Finance, Legal)?
- [ ] Direct authority [ ] Has access [ ] Unknown
### M — Money
- Is there budget allocated?
- If not, how does budget get created?
- What does the problem cost them today?
- [ ] Allocated [ ] Available [ ] None yet
### E — Estimated Timing
- When do they need to solve this?
- When does budget unlock?
- What's the decision timeline?
- [ ] This quarter [ ] This year [ ] Next year [ ] Unknown
### Verdict
- Deal tier: [ ] A [ ] B [ ] C
- Missing factors: [list any]
- Next steps: [specific action]
```
## Technology Tourist Detection Script
Use these questions on any prospect who seems interested but won't commit:
1. "Have you brought similar technology into your company before?"
- Yes, recently → likely real buyer
- Yes, years ago → possibly real but assess authority
- No, this would be first → tourist signal
2. "Who besides you will be involved in the decision?"
- Clear list → real process
- Vague "just me" → tourist signal (at F500 size, nothing is just one person)
- "Not sure yet" → tourist signal
3. "What's your timeline for making a decision?"
- Specific date → real
- Vague "when ready" → tourist signal
4. "What happens if you don't solve this in the next 6 months?"
- Specific consequences → real need
- "Nothing urgent" → tourist confirmed
## False Change Agent Detection
Signs the person is not actually a change agent:
- Tenure under 6-12 months at the company
- Title doesn't match authority claims
- Over-emphasizes their influence ("I can make this happen")
- Can't name specific past changes they drove
- Doesn't introduce you to anyone else
- Avoids involving their boss or stakeholders
Test question: "Can you walk me through a similar technology decision you drove here in the past?"
A real change agent has a specific story. A false one has generalities.
## Source
Chapter 18 ("Sales") of *Traction* by Gabriel Weinberg and Justin Mares. SPIN Selling is attributed to Neil Rackham; Sean Murphy is cited for the wrong-first-customer traps.
介绍Elastic从开源搜索库到综合可观测性平台的发展,涵盖Elasticsearch、ELK Stack及其商业模式。
--- name: elastic-datadog summary: 从开源搜索库到百亿美金可观测性平台 — Elastic 如何改变数据搜索和分析 read_when: - 研究可观测性和日志管理时 - 分析 Elasticsearch 生态系统时 - 了解开源软件商业化路径时 - 对比 Splunk、Datadog、Elastic 时 --- # Elastic (Elasticsearch) ## 概述 从开源搜索库到百亿美金可观测性平台 — Elastic 如何改变数据搜索和分析。 ## 历史时间线 - 2012: Shay Banon 创建 Elasticsearch(基于 Lucene 的分布式搜索引擎) - 2012: Elasticsearch 迅速成为最流行的开源搜索项目 - 2012: 创立 Elastic 公司商业化 Elasticsearch - 2015: 发布 ELK Stack(Elasticsearch + Logstash + Kibana) - 2018年10月: 纽交所上市(股票代码 ESTC) - 2019: 推出 Elastic Security(SIEM) - 2021: 推出 Elastic Observability(APM + 日志 + 指标 + Uptime) - 2023: 推出 Elastic AI Assistant(LLM 集成) ## 商业模式 开源核心模式(Open Source Core):免费开源版 + 付费商业版(Elastic Cloud 托管服务、安全功能、机器学习功能)。收入来自:Elastic Cloud 订阅、商业许可证、企业支持合同。 ## 护城河分析 Elasticsearch 是业界最流行的搜索引擎(维基百科、GitHub、Uber 等都使用);ELK Stack 是日志管理的行业标准;开源社区生态庞大;全栈可观测性平台。 ## 关键数据 - **上市**: 纽交所 ESTC(2018) - **2023年营收**: ~13 亿美元 - **GitHub Stars**: Elasticsearch 67k+ - **用户**: 包括 Netflix, NASA, Uber 等 ## 有趣事实 - Elasticsearch 最初是创始人 Shay Banon 为了让妻子的菜谱更容易搜索而创建的——后来变成了价值百亿美元的搜索引擎 - Elastic 公司 2019 年与 AWS 发生许可证争议,导致 AWS 创建了 OpenSearch 分叉项目
Use when a user wants to learn a GitHub or local codebase by understanding its implementation principles, creating a minimal runnable practice version, or tu...
---
name: repo-mini-practice
description: Use when a user wants to learn a GitHub or local codebase by understanding its implementation principles, creating a minimal runnable practice version, or turning source reading into hands-on exercises.
---
# Repo Mini Practice
## Purpose
Turn a real repository into a small, runnable learning project. The goal is not to summarize every file; it is to extract one core mechanism and rebuild it as a clear `mini-practice/` inside the target repository.
## Use When
- The user wants to understand a GitHub repository or local codebase beyond README usage.
- The user asks for a mini, MVP, VIP, tiny clone, source-learning version, or runnable practice implementation.
- The user wants to learn the principle behind a library, framework, CLI, SDK, agent, compiler tool, service, or app.
Do not use this for ordinary code review, bug fixing, or feature implementation unless the user's goal is explicitly learning through a minimal reproduction.
## Core Rule
Create a runnable teaching artifact, not just an explanation. If the real project depends on external services, databases, model APIs, queues, browsers, or infrastructure, mock those parts and preserve the core logic flow.
Default documentation is bilingual: write the learning guide in Chinese and English unless the user asks for a single language.
Code comments follow the user's prompt language. If the user asks in Chinese, write function comments in Chinese; if the user asks in English, write them in English. If the prompt mixes languages, use the dominant language or the language explicitly requested by the user.
## Workflow
1. **Confirm the target**
- If given a local path, inspect that repository.
- If given a GitHub URL, use available git or GitHub tools to obtain or inspect it. Ask for approval first if the environment requires network permission.
- If no target is clear, ask for the repo path or URL.
2. **Read the public contract**
- Read README, examples, docs, package manifests, CLI help, or demo entry points.
- State what the project appears to do in one or two sentences.
3. **Map the source**
- Identify entry points, core modules, important data structures, and the main call chain.
- Prefer fast search tools such as `rg` or language-native symbol tools.
- Ignore generated files, lockfiles, build output, vendored dependencies, and unrelated examples unless they are the only usage signal.
4. **Choose one mechanism**
- Pick one high-value mechanism that can be rebuilt in a small runnable form.
- Examples: middleware pipeline, state update loop, router matching, SQL generation, retry scheduler, parser-transformer-generator chain, tool-calling loop, cache invalidation, plugin loading.
- If several choices are plausible, briefly name two or three and pick the best default.
5. **Design the mini-practice**
- Explain what will be reproduced.
- Explain what will be mocked or omitted.
- Use the repository's primary language and ecosystem when practical.
- Keep the implementation small enough to read in one sitting.
6. **Create `mini-practice/`**
- Do not overwrite an existing `mini-practice/` without inspecting it and preserving user work.
- Include a minimal runnable demo, test, or command.
- Add function-level comments for the important learning functions. Explain the role of each function in the mechanism, what input shape it expects, what it returns or mutates, and which original source concept it mirrors.
- Write code comments in the same language the user used to ask for the mini-practice, unless the user explicitly requests another language.
- Keep comments focused on the underlying principle and links to the original source concepts. Avoid noisy syntax comments such as "increment i by one."
7. **Verify it runs**
- Run the demo or tests.
- If verification cannot run because dependencies or network are unavailable, explain exactly what was attempted and provide the closest local check.
8. **Write the learning guide**
- In `mini-practice/README.md`, include:
- the chosen core mechanism
- run commands
- file-by-file reading order
- how mini files correspond to original source files or concepts
- what is mocked or omitted
- two or three small exercises for the learner
- By default, include both Chinese and English sections. Keep the two versions equivalent, but concise.
## Output Shape
Prefer this structure, adapting names to the language ecosystem:
```text
mini-practice/
├── README.md
├── src/
├── tests/ or demo/
├── mocks/ or fixtures/
└── minimal package/config files
```
## Comment and README Requirements
- Add detailed comments to public functions, factory functions, core callbacks, and non-obvious data transformations.
- Comments should teach the architecture: why the function exists, where it sits in the flow, and how changing it affects behavior.
- Comment language follows the user's request language. Do not make code comments bilingual by default; bilingual comments make the mini implementation noisy.
- Avoid commenting every line. Prefer one strong comment before a function or important block.
- `README.md` should default to this shape:
- Chinese overview, run commands, reading path, original concept mapping, exercises.
- English overview, run commands, reading path, original concept mapping, exercises.
- If the user asks for one language, honor that preference.
## Quality Bar
- One complete core loop is better than many partial features.
- The code must execute; avoid pseudo-code.
- The mini version should be easier to understand than the original source while preserving the key principle.
- Use plain names and comments that explain principles, boundaries, and data flow.
- The README is bilingual by default and can be read independently of the chat transcript.
- Keep the original repository untouched except for the new or updated `mini-practice/` directory.
- Report exact commands run and their outcomes.
## Common Mistakes
- Summarizing the README without reading the implementation.
- Building a toy that no longer resembles the source principle.
- Copying too much original code instead of recreating the mechanism for learning.
- Trying to cover the whole repository.
- Skipping verification because the mini version "looks simple."
FILE:agents/openai.yaml
interface:
display_name: "Repo Mini Practice"
short_description: "Build runnable mini versions of repositories"
default_prompt: "Use $repo-mini-practice to study this repository by creating a runnable mini-practice version of its core mechanism."
policy:
allow_implicit_invocation: true
Generates timed, hook-driven Douyin short video scripts with visual cues, BGM suggestions, and CTA lines optimized for platform retention and engagement.
# Douyin Short Video Script Studio ## Purpose This skill generates structured oral presentation scripts for Douyin (抖音 / TikTok CN) short videos. It specializes in hook-driven openings (0–3 second grab), timed content beats, visual cue suggestions, BGM mood guidance, and transition scripting. "Studio" means a complete toolkit — from brief to shoot-ready script with timing, not just flat text. Best used when you need a Douyin video that converts attention into retention, with every second engineered for the platform's algorithm and viewer behavior. ## Triggers - "写抖音脚本" - "抖音口播" - "短视频脚本" - "抖音 hook" - "抖音 storyboard" - "douyin script" - "抖音文案" - "Douyin video script" - "口播稿" - "抖音分镜脚本" ## Workflow 1. Receive product/topic brief from user: product name, category, key message, target audience, desired video style/tone, and video length (15s, 30s, or 60s). 2. Determine the optimal script structure based on length: - 15s: Single-hook, single-point, hard CTA - 30s: Hook → Problem/Context → Solution/Reveal → CTA - 60s: Hook → Story/Proof → Deep Dive → Social Proof → CTA 3. Generate 3–5 opening hook variants optimized for 0–3 second retention (visual + verbal). 4. Structure body beats with estimated timing per segment (e.g., 0–3s hook, 3–8s setup, 8–20s core message). 5. Add visual cues for each beat: shot type (close-up, product detail, face-to-camera), motion direction, text overlay suggestions, and transition type (cut, zoom, swipe). 6. Suggest BGM mood and tempo (upbeat, emotional, trending, lo-fi) matched to content energy. 7. Write the closing CTA optimized for Douyin algorithm engagement: like, follow, comment prompt, or purchase link. 8. Include safety disclaimer and compliance review for commercial content. ## Prompt Templates ### 1. Script from Brief (`script_from_brief`) **Purpose:** Generate a complete timed Douyin oral script from a product/topic brief. **Input:** - `product_name` — Product or topic name - `category` — Niche (beauty, tech, food, lifestyle, education, fitness) - `key_message` — The single most important point to communicate - `target_audience` — Who the video is for (age, interest, pain point) - `video_length` — 15s, 30s, or 60s - `tone` — Style (energetic, calm, humorous, authoritative, relatable) - `cta_goal` — Desired action (follow, like, comment, buy, download) **Output:** Full timed script with: - Timestamped beats (0–3s, 3–8s, etc.) - Spoken lines (口播文案) - Visual cues per beat (shot, motion, text overlay) - BGM mood suggestion - Final CTA line ### 2. Hook Library (`hook_library`) **Purpose:** Generate 5 opening hook variants for a product or topic. **Input:** - `product_name` — Product or topic - `hook_type` — Optional preference (curiosity, pain point, surprise, story, number/list) - `target_audience` — Audience descriptor **Output:** 5 hook options, each with: - Verbal hook (first 1–2 sentences) - Visual direction (what to show in 0–3s) - Why it works (psychology rationale) - Best fit scenario ### 3. Storyboard Outline (`storyboard_outline`) **Purpose:** Convert a script brief into a 3-scene visual storyboard outline. **Input:** - `product_name` — Product/topic - `scene_count` — 3 or 5 scenes - `style` — Visual style (clean, lifestyle, demo, testimonial, Vlog) **Output:** Scene-by-scene breakdown: - Scene number + timestamp range - Shot description (angle, distance, subject) - On-screen text overlay suggestions - Audio notes (voiceover vs. music vs. silence) - Transition to next scene ### 4. Trending Angle Adapter (`trending_angle_adapter`) **Purpose:** Adapt a product/topic to a current Douyin trending format or challenge style. **Input:** - `product_name` — Product/topic - `trend_format` — Trending format (e.g., "before vs after", "day in the life", "myth busting", "POV", "trending sound rewrite") - `original_script` — (Optional) Existing script to adapt **Output:** Adapted script/outline that fits the trending format while preserving the core product message, with notes on how to make it feel native to the trend rather than forced. ### 5. Script Optimizer (`script_optimizer`) **Purpose:** Improve an existing Douyin script for retention, clarity, and conversion. **Input:** - `draft_script` — User's existing script or outline - `optimization_goal` — Primary goal (retention, clarity, conversion, humor, pacing) - `video_length` — Target length **Output:** Optimized script with: - Redlined changes (what changed and why) - Timing adjustments - Stronger hook alternatives - Visual enhancement suggestions - Pacing notes (where to speed up, where to pause) ## Output Format All script outputs follow a structured studio format: ``` ## Douyin Script: [Product/Topic] **Length:** [15s / 30s / 60s] | **Tone:** [Tone] | **CTA Goal:** [Goal] ### Beat 1 — Hook (0–3s) - **Script:** [Spoken line] - **Visual:** [Shot type + motion + text overlay] - **Audio:** [BGM mood / sound effect] ### Beat 2 — [Segment Name] (3–8s) ... ### Closing — CTA (final 3s) - **Script:** [CTA line] - **Visual:** [End card / product shot / follow prompt] - **Audio:** [Music swell / silence for impact] ``` Additional outputs provided as needed: - **Hook variants:** Bulleted list with rationale - **Storyboard:** Table format (Scene | Time | Shot | Text | Audio | Transition) - **Optimization notes:** Before/After comparison with reasoning ## Safety Rules - **NEVER** generate false product efficacy claims or misleading before/after transformations - **NEVER** suggest dangerous challenges, risky behaviors, or harmful stunts for views - **NEVER** create scripts that impersonate real individuals without disclosure - **ALWAYS** include explicit disclosure language for sponsored or commercial content (e.g., "本条内容为合作推广" or "#ad") - **ALWAYS** respect Douyin content review policies — no prohibited products, medical claims, or deceptive practices - **ALWAYS** remind the user to review and fact-check AI-generated scripts before filming and publishing - **ALWAYS** ensure visual cues and suggested actions comply with platform safety guidelines ## Examples ### Example 1: Script from Brief (Skincare, 30s) **Input:** Product="XX 维C精华", Category="beauty", Key Message="7天提亮肤色", Audience="20-30岁熬夜女性", Length="30s", Tone="energetic", CTA Goal="buy" **Output:** - Beat 1 (0–3s): Hook — "熬夜脸有救了!" + close-up of tired face → brightened face transition - Beat 2 (3–10s): Problem — "凌晨2点睡,早上暗沉到不敢照镜子" + lifestyle shot - Beat 3 (10–22s): Solution — "这瓶维C精华,7天提亮不是玄学" + product demo + ingredient text overlay - Beat 4 (22–30s): CTA — "链接在左下角,现在下单立减30" + end card with price ### Example 2: Hook Library (Same Product) **Input:** Product="XX 维C精华", Hook Type="curiosity", Audience="20-30岁女性" **Output:** 5 hooks: 1. "我用了7天,同事问我是不是去做医美了" (surprise + social proof) 2. "这瓶精华的维C浓度,我算了3遍才敢相信" (curiosity + number) 3. "熬夜到凌晨2点,我的脸居然比以前还亮" (contrast + relatability) 4. "皮肤科朋友偷偷告诉我,提亮根本不需要贵" (insider + value) 5. "别再花冤枉钱!提亮肤色,这一瓶够了" (direct + authority) ### Example 3: Storyboard Outline (Tech Gadget, 5 scenes) **Input:** Product="便携投影仪", Style="lifestyle" **Output:** 5-scene storyboard from unboxing → bedroom setup → movie night → portability demo → CTA end card. ## Related Skills - [live-selling-script-kit](../live-selling-script-kit/) — For live-streaming sales scripts when your Douyin video drives to a live room - [ad-copy-ab-tester](../ad-copy-ab-tester/) — For testing Douyin ad creative copy derived from these scripts - [landing-page-copy-pro](../landing-page-copy-pro/) — For landing page copy when your Douyin CTA drives traffic to a conversion page FILE:ACCEPTANCE.md # Acceptance Criteria — Douyin Short Video Script Studio - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules are explicit and actionable (NEVER/ALWAYS format) - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields, `requires_api: false` - [ ] Content is unique — no duplication with other skills in this pack (focus on timed video beats, visual cues, and BGM mood) - [ ] Slugs follow naming convention (user-facing, no prefix codes) - [ ] Hook library and storyboard outline features are differentiated from viral-xiaohongshu-notes (video scripts vs. text notes) FILE:README.md # Douyin Short Video Script Studio Structured oral presentation scripts for Douyin (抖音) short videos — engineered for 0–3s hooks, timed beats, and shoot-ready production. ## Features - Generate complete timed scripts from briefs (15s / 30s / 60s) - Hook library: 5 opening variants with psychology rationale - Storyboard outlines with shot types, text overlays, and transitions - Trending angle adapter: fit your product into current Douyin formats - Script optimizer: improve existing drafts for retention and conversion - Visual cues and BGM mood guidance per beat ## Install ``` openclaw skills install harrylabsj/douyin-script-studio ``` ## Usage ``` 帮我写一个30秒的抖音口播脚本,产品是XX维C精华,主打7天提亮,面向20-30岁熬夜女性,语气活泼,目标是下单 给我5个抖音视频开头hook,产品是便携投影仪,面向租房年轻人 把这个脚本改成"Day in the life"的抖音热门形式 帮我优化这个抖音脚本的节奏和转化 ``` ## Platforms 抖音 (Douyin / TikTok CN) ## Safety No false efficacy claims. No misleading before/after. No dangerous challenges. All commercial content includes sponsorship disclosure. Always review scripts before filming. ## License MIT FILE:skill.json { "name": "Douyin Short Video Script Studio", "description": "Structured Douyin oral script generation with hook-driven openings (0-3s grab), timed content beats, visual cues, BGM mood guidance, and transition scripting for short video creators.", "version": "1.0.0", "type": "prompt-flow", "category": "Social Media Content / Platform-Specific", "keywords": [ "douyin", "抖音", "抖音脚本", "口播脚本", "short video script", "opening hook", "storyboard", "trending", "抖音文案", "video script", "creator toolkit" ], "platforms": ["抖音 (Douyin / TikTok CN)"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No false product efficacy claims. No misleading before/after transformations. Explicit disclosure of commercial/sponsored content. No dangerous challenge or behavior suggestions. Respect Douyin content review policies." } }
Generates Xiaohongshu native notes with authentic product recommendations, aesthetic formatting, niche hashtags, cover texts, and a commercial disclosure rem...
# Viral Xiaohongshu Note Writer ## Purpose This skill generates Xiaohongshu (小红书 / RED) platform-native notes optimized for virality. It creates "种草" (grass-planting / product recommendation) content with cover text design strategy, niche hashtag stacking, authentic personal-experience tone, product placement angles, and platform-unique aesthetic formatting. Best used when you have a product or service to promote and need a note that feels organic, engaging, and platform-appropriate — but still delivers commercial value. ## Triggers - "写小红书笔记" - "生成种草文案" - "小红书 cover" - "小红书 hashtag" - "种草角度" - "小红书改写" - "viral xiaohongshu note" - "xhs note writer" - "RED note generator" - "小红书内容创作" ## Workflow 1. Receive product information from user (product name, category, key features, price, target audience, and optional existing draft). 2. Identify niche: beauty, fashion, travel, food, home, parenting, or general lifestyle. 3. Structure the note using the Xiaohongshu native format: hook → personal experience → product reveal → usage tips → purchase guidance. 4. Insert emoji rhythm, line breaks, and section headers following Xiaohongshu aesthetic conventions. 5. Generate 3–5 niche-specific hashtags plus 2–3 trending tags for discoverability. 6. Provide 3–5 cover text options that match the note angle. 7. Include safety disclaimer reminding user to disclose commercial relationships. ## Prompt Templates ### 1. Note from Brief (`note_from_brief`) **Purpose:** Generate a complete Xiaohongshu note from product information. **Input:** - `product_name` — Name of the product - `category` — Niche (beauty/fashion/travel/food/home/parenting) - `key_features` — 2–4 main selling points - `target_audience` — Who this product is for - `price_range` — Optional price context - `angle` — Optional content angle (e.g., "成分党", "学生党", "干货分享") **Output:** Full note with hook paragraph, personal experience narrative, product reveal, usage tips, purchase guidance, hashtags, and 3 cover text options. ### 2. Cover Title Generator (`cover_title_generator`) **Purpose:** Generate cover image text options that drive clicks. **Input:** - `product_name` — Product name - `angle` — Content angle - `target_audience` — Audience descriptor **Output:** 5 cover title options, each with a rationale for why it works for the given product and audience. ### 3. Hashtag Strategy (`hashtag_strategy`) **Purpose:** Create a balanced hashtag set for maximum discoverability. **Input:** - `product_category` — Category (e.g., 面膜, 穿搭, 旅行) - `niche_keywords` — 2–3 niche-specific keywords - `trending_context` — Optional current trending topics or seasons **Output:** 3–5 niche hashtags (targeting specific interest groups) + 2–3 trending hashtags (for broader reach) + hashtag volume tier labeling. ### 4. Angle Switcher (`angle_switcher`) **Purpose:** Generate 3 different content angles for the same product. **Input:** - `product_name` — Product name - `key_features` — Key features - `audience_segments` — 2–3 possible audience types **Output:** 3 distinct note outlines, each from a different angle (e.g., 成分分析, 使用前后对比, 开箱体验), with hook and hashtag recommendations per angle. ### 5. Note Polish/Rewrite (`note_rewrite`) **Purpose:** Optimize an existing draft for Xiaohongshu engagement. **Input:** - `draft_content` — User's existing note draft - `optimization_goal` — What to improve (engagement/readability/SEO) **Output:** Polished version with improved hook, emoji rhythm, formatting, hashtags, and cover text suggestions. ## Output Format All outputs follow Xiaohongshu's native platform styling: - Short paragraphs (1–3 sentences each) - Emoji used deliberately for emphasis and section breaks - Hashtags appended at the bottom - Cover text options provided separately as a numbered list - Character count within platform limits (~1000 characters) ## Safety Rules - **NEVER** generate fake reviews, fabricated user experiences, or misleading testimonials - **NEVER** make unverified product efficacy claims (especially skincare, health, or wellness) - **NEVER** include medical/health claims without qualification (e.g., "FDA-registered" or "dermatologist-tested" only if verifiable) - **ALWAYS** prompt the user to disclose sponsored or commercial relationships per Xiaohongshu guidelines - **ALWAYS** respect Xiaohongshu community guidelines — no prohibited products or content - **ALWAYS** remind the user to review and fact-check AI-generated content before publishing ## Examples ### Example 1: Note from Brief (Skincare) **Input:** Product = "XX 玻尿酸保湿面霜", Price = "299元", Features = "三重玻尿酸、敏感肌可用、24小时保湿", Audience = "25-35岁女性", Angle = "成分党" **Output:** A full note with hook about winter skincare struggles, personal experience with dry skin, product reveal with ingredient breakdown (triple hyaluronic acid), usage tips (apply on damp skin), and hashtags like #玻尿酸面霜 #保湿面霜推荐 #干皮救星 #成分党 skincare. ### Example 2: Angle Switcher (Same Product) **Input:** Same product as above, audience segments = {成分党, 学生党, 宝妈} **Output:** Three outlines: (1) 成分分析 deep-dive, (2) 平价好物 budget-friendly angle, (3) 新手护肤 routine integration angle. ## Related Skills - [social-caption-kit](../social-caption-kit/) — For multi-platform repurposing of the same content - [product-title-booster](../product-title-booster/) — For optimizing the product's listing title to match the note - [review-reply-coach](../review-reply-coach/) — For responding to comments and reviews on the note FILE:ACCEPTANCE.md # Acceptance Criteria — Viral Xiaohongshu Note Writer - [ ] SKILL.md is self-contained (agent can operate from it alone) - [ ] All 5 prompt templates are complete with `placeholder` inputs - [ ] Safety rules are explicit and actionable (NEVER/ALWAYS format) - [ ] README.md has clear install instructions + 3 usage examples - [ ] skill.json is valid JSON with all required fields - [ ] Content is unique — no duplication with other skills in this pack (focus on Xiaohongshu-native 种草 format) - [ ] Slugs follow naming convention (user-facing, no prefix codes) - [ ] Cover text generator, hashtag strategy, and angle switcher features differentiated from social-caption-kit FILE:README.md # Viral Xiaohongshu Note Writer Create authentic, engaging Xiaohongshu (RED) notes optimized for virality and platform-native aesthetics. ## Features - Generate complete notes from product briefs with native 种草 tone - Craft click-optimized cover text options for your images - Build balanced hashtag strategies (niche + trending) - Explore multiple content angles for the same product - Polish and rewrite existing drafts for better engagement - Emoji rhythm, line breaks, and Xiaohongshu-native formatting ## Install ``` openclaw skills install harrylabsj/viral-xiaohongshu-notes ``` ## Usage ``` 写一篇小红书笔记,产品是XX面霜,299元,主打保湿和修护,面向25-35岁女性,成分党角度 帮我生成5个小红书封面标题,产品是便携咖啡机,面向职场白领 给我3个不同的种草角度写同一款洁面产品 帮我优化这篇小红书笔记的标题和hashtag ``` ## Platforms 小红书 (Xiaohongshu / RED) ## Safety This skill does not generate fake reviews or fabricated user experiences. All outputs include reminders to disclose commercial relationships per platform guidelines. Always review AI-generated content before publishing. ## License MIT FILE:skill.json { "name": "Viral Xiaohongshu Note Writer", "description": "Generate viral-style Xiaohongshu (RED) notes with cover text, niche hashtag strategy, authentic 种草 tone, and platform-optimized formatting for beauty, fashion, travel, food, home, and parenting niches.", "version": "1.0.0", "type": "prompt-flow", "category": "Social Media Content / Platform-Specific", "keywords": [ "xiaohongshu", "小红书", "种草文案", "小红书笔记", "RED note", "cover text", "hashtag strategy", "viral content", "种草", "product recommendation", "beauty note", "fashion note" ], "platforms": ["小红书 (Xiaohongshu / RED)"], "requires": {}, "requires_api": false, "author": "harrylabsj", "license": "MIT", "safety": { "no_code_execution": true, "no_network": true, "no_credentials": true, "compliance_notes": "No fake reviews or fabricated user experiences. No unverified product efficacy claims. No medical/health claims without qualification. Must prompt user to disclose commercial relationships. Respect Xiaohongshu community guidelines." } }
Guide startup PR and unconventional PR outreach using the media chain, pitch templates, and amplification tactics. Use whenever a founder or marketer needs t...
---
name: startup-pr-outreach
description: "Guide startup PR and unconventional PR outreach using the media chain, pitch templates, and amplification tactics. Use whenever a founder or marketer needs to pitch reporters, plan a PR campaign, land media coverage, run a publicity stunt, amplify a press story, use HARO, build reporter relationships, or avoid common PR mistakes. Also covers unconventional PR (stunts, customer appreciation) for startup launches. Activates on phrases like 'press release', 'PR campaign', 'media coverage', 'reporter outreach', 'pitch email', 'TechCrunch', 'HARO', 'product launch', 'PR strategy', 'publicity stunt', 'get coverage', 'press pitch', 'media pitch', 'journalist outreach'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/startup-pr-outreach
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [7, 8]
domain: startup-growth
tags: [startup-growth, public-relations, media-outreach, press-pitching, launch-marketing]
depends-on: [bullseye-channel-selection]
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Company milestones, target media outlets, pitch angles, launch details"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for pitch drafts and media outreach tracker"
discovery:
goal: "Produce a PR campaign plan with pitch drafts, target outlet list, and amplification sequence"
tasks:
- "Identify a milestone worth PR coverage"
- "Build a media chain starting with small blogs"
- "Draft pitches using the two proven templates"
- "Apply the emotional angle criteria"
- "Avoid the 6 named PR pitching mistakes"
- "Plan the amplification sequence after coverage lands"
audience:
roles: [startup-founder, head-of-marketing, pr-lead]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "Product launch approaching"
- "Startup has a newsworthy milestone"
- "Previous PR attempts produced no coverage"
- "Bullseye selected PR as inner-circle channel"
prerequisites: []
not_for:
- "Phase I startups with nothing newsworthy yet (use targeting blogs instead)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 12
iterations_needed: 0
---
# Startup PR Outreach
## When to Use
The startup needs media coverage or is planning a PR campaign. Use this skill when:
- A newsworthy milestone has happened or is about to happen (funding, launch, usage threshold, partnership)
- The user wants to reach a broad audience via trusted intermediaries (reporters)
- Previous PR attempts produced no coverage
- Planning a publicity stunt or unconventional PR tactic
PR is typically a Phase II+ channel. Phase I startups without newsworthy milestones should use targeting blogs instead.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Milestone / angle:** what's actually newsworthy
→ Check prompt for: "launching", "raised", "hit X users", "partnership with"
→ If missing, ask: "What specific milestone are you trying to get coverage for? Launches, funding, user thresholds, and industry partnerships typically work. Vague product announcements don't."
- **Target audience:** who the story should reach
→ Check prompt for: developer, consumer, enterprise buyer, specific vertical
→ If missing, ask: "Who is the ideal reader? That determines which outlets and reporters to target."
### Observable Context
- **Existing media relationships:** prior coverage, reporter connections
- **Spokesperson availability:** founder, press-ready team members
### Default Assumptions
- Start with small blogs, not top-tier outlets (media chain principle)
- Founders pitch better than PR firms for early-stage startups
- Bundle smaller announcements into bigger ones when possible
### Sufficiency Threshold
```
SUFFICIENT: milestone + target audience known
PROCEED WITH DEFAULTS: milestone known, infer target from context
MUST ASK: no milestone exists (not newsworthy)
```
## Process
Use TodoWrite:
- [ ] Step 1: Identify/bundle the newsworthy angle
- [ ] Step 2: Build the media chain (small → top)
- [ ] Step 3: Build reporter relationships via Twitter/HARO
- [ ] Step 4: Draft pitches using proven templates
- [ ] Step 5: Plan amplification sequence
### Step 1: Identify and Bundle the Newsworthy Angle
**ACTION:** Determine what's actually newsworthy. Strong angles include:
- Funding round (especially with notable investors)
- Product launch with a specific unique hook
- Usage threshold crossed (1M users, 100k searches, etc.)
- Partnership with a recognizable brand
- A stunt or unconventional event (see unconventional PR)
- An industry report or data set only you have
**Bundle smaller announcements.** Jason Kincaid's advice: don't pitch small milestones individually if they can be combined. "Launched feature X" is weak. "Launched feature X + hit 10k users + signed partnership with Y" is strong.
The **emotional angle test**: ask "will this elicit an emotion in readers beyond satisfaction?" Satisfaction is a non-viral emotion. Stories that make readers share need to produce surprise, delight, outrage, or curiosity.
**WHY:** Reporters receive 50+ pitches daily. The first filter is "is this actually a story?" Bundled, emotionally-engaging milestones clear the filter. Single-milestone pitches get ignored. This isn't about hype — it's about giving the reporter enough material to write an interesting article.
**IF** no angle emerges → delay PR, build more milestones first, or pivot to targeting blogs for content-led coverage.
### Step 2: Build the Media Chain (Small → Top)
**ACTION:** Stories filter UP the media chain. Small blogs → TechCrunch → New York Times. Start small, not at the top.
Identify the chain for your category:
- **Level 1 (entry):** Hacker News, Reddit, Product Hunt, niche industry blogs, HARO responses
- **Level 2 (mid-tier):** TechCrunch, The Verge, Wired, industry publications
- **Level 3 (top-tier):** NYT, WSJ, mainstream TV, national podcasts
Target Level 1 first. Top outlets (Level 2-3) often pick up stories from Level 1. DuckDuckGo's Time Magazine feature came via a Twitter relationship with a reporter who then included DDG in a Top 50 list — not via a cold pitch to Time.
**WHY:** Cold-pitching top outlets has near-zero success rate. Most top reporters scan Hacker News, Reddit, and small blogs looking for stories. Starting at Level 1 puts the story where top reporters are already looking. This is how stories naturally filter up — respecting the mechanic dramatically increases success.
### Step 3: Build Reporter Relationships
**ACTION:** Before pitching, identify and engage reporters who cover your category. Twitter is the easiest channel — many reporters have surprisingly few followers and engage with thoughtful replies.
Tactics:
- Follow reporters who cover your space
- Reply to their tweets with genuine context (not pitches)
- Respond to HARO (Help A Reporter Out) queries — this creates mentions and warm introductions
- Bookmark reporters' email addresses before you need them
**WHY:** Cold pitches to strangers have 1-2% response rates. Pitches from people a reporter recognizes from prior Twitter interactions have dramatically higher response rates. The relationship doesn't need to be deep — recognition alone is often enough. HARO is a fast path to a first mention, which then becomes social proof for the next outreach.
**IF** there's no time to build relationships organically → HARO is the fastest substitute. Answer 3-5 relevant queries weekly.
### Step 4: Draft Pitches Using Proven Templates
**ACTION:** Use one of the two templates from [references/pitch-templates.md](references/pitch-templates.md):
1. **Direct pitch:** Subject line with exclusive hook, short paragraphs (hook + product + demo link + exclusive offer), direct contact info at bottom.
2. **Ryan Holiday template:** Subject "Quick question", reference their prior work, tease the exclusive, give specific results ("25,000 paying customers in 2 months"), ask for their process.
Critical criteria for any pitch:
- Short — reporters scan, don't read
- Emotional hook — not "we built a product"
- Concrete specifics — numbers, names, dates
- One clear angle — not 3 competing ones
- Exclusive offer when possible (first access, embargo, data)
Run the **6 PR mistakes check** — see [references/pr-mistakes.md](references/pr-mistakes.md).
**WHY:** Pitch format matters more than most founders realize. The difference between a 50-word pitch and a 500-word pitch is a 10x response rate difference. Templates prevent founders from writing the "wall of text" mistake. The mistakes check prevents the most common failure modes (wall of text, bad timing, no emotional angle, PR firm via, unclear launch timing, bundling failures).
### Step 5: Plan the Amplification Sequence
**ACTION:** Coverage is step 1. Amplification is what turns coverage into traction. For each piece of coverage that lands:
1. **Submit to community sites:** Hacker News, Reddit, Product Hunt, Slashdot (category-appropriate), Digg
2. **Share on social:** Twitter, LinkedIn, Facebook, with founder personal accounts amplifying
3. **Pay to boost:** Run social ads pointing to the coverage page (often cheaper than ads pointing to landing pages)
4. **Email your list:** Point subscribers to the coverage
5. **Contact tier 2 reporters:** Share the coverage as evidence that the story has traction, invite follow-up
Write the amplification plan to `pr-amplification.md`.
**WHY:** A TechCrunch feature sends traffic for 24-48 hours. Amplification extends the half-life and creates the chain reaction that drives stories up to top-tier outlets. Founders who skip amplification get coverage but not the compounding effect coverage enables.
## Inputs
- Newsworthy milestone or bundled announcement
- Target audience
- Media chain for the category
- Reporter contact list (or plan to build one)
## Outputs
Four markdown files:
1. **`pr-angle.md`** — The story, bundled milestones, emotional hook
2. **`pr-media-chain.md`** — Target outlets by tier
3. **`pr-pitches.md`** — Draft pitches (direct + Ryan Holiday variants)
4. **`pr-amplification.md`** — Post-coverage amplification sequence
## Key Principles
- **Stories filter UP the media chain.** Don't start at the top. WHY: Top reporters get their ideas from small blogs. Starting at the top means cold-pitching someone who doesn't know you. Starting small means your story shows up where top reporters are already looking.
- **Bundle, don't drip.** One big announcement beats five small ones. WHY: Reporters want material. A bundled announcement gives them enough for a real article. Drip announcements get ignored individually.
- **Emotional angle trumps feature list.** Reporters need readers to share the story. Shares come from emotion, not features. WHY: "Satisfaction is a non-viral emotion." Stories worth sharing produce surprise, outrage, delight, or curiosity.
- **Founders pitch better than PR firms at early stage.** Most reporters ignore PR firm pitches. Founder pitches are more personal and show the founder cares. WHY: PR firms cost money and produce lower response rates for early-stage companies. Save the money, do it yourself, and learn the skill.
- **Amplification is mandatory.** Coverage without amplification is wasted potential. WHY: A single piece of coverage produces 24-48 hours of attention. Amplification extends it by weeks and creates the chain reaction to top-tier outlets.
- **Twitter is the reporter relationship channel.** Many reporters have accessible Twitter follower counts. WHY: LinkedIn and email are crowded. Twitter engagement is casual enough that reporters actually read replies. A month of thoughtful replies beats 50 cold emails.
## Examples
**Scenario: B2B SaaS product launch**
Trigger: "We're launching our analytics tool in 4 weeks. Want TechCrunch coverage. What should we do?"
Process: (1) Bundle milestones: launch + seed funding + 3 pilot customers = one big story. (2) Media chain: Product Hunt launch, Hacker News post, targeted tier-1 analytics blogs → tier-2 TechCrunch/VentureBeat → tier-3 coverage unlikely for early-stage. (3) Relationships: 4 weeks isn't enough to build organic relationships, so HARO + Twitter engagement with 5 reporters who cover analytics. (4) Pitches: direct pitch template, emphasize exclusive access, specific pilot customer results. (5) Amplification: day-of Hacker News + Product Hunt submission, founder Twitter thread, paid social boost to coverage URL.
Output: Week-by-week PR plan with pitch drafts, specific reporters, and amplification checklist.
**Scenario: Previous PR attempts failed**
Trigger: "We sent 30 pitches to TechCrunch reporters last month and got zero responses. What's wrong?"
Process: (1) Diagnose: cold-pitching top outlets directly is the most common PR mistake. Show the media chain — stories filter up, not down. (2) Review the pitches — apply the 6 mistakes check. Usually at least 3 apply (wall of text, no emotional hook, no clear angle, unclear timing). (3) Re-strategy: start at small blogs and HARO. Build Twitter relationships with 3-5 TechCrunch reporters over 4-6 weeks BEFORE any pitch. (4) Rewrite pitches using the Ryan Holiday template. (5) Amplification plan for when coverage lands.
Output: Diagnosis of why previous approach failed, corrected approach, and new pitch drafts.
**Scenario: Unconventional PR stunt**
Trigger: "We want to do a publicity stunt like Dollar Shave Club's video or Half.com renaming a town. What makes these work?"
Process: (1) Analyze the pattern: unique + surprising + shareable + on-brand. (2) Generate stunt ideas tied to the company's actual product (not random). (3) Evaluate each against emotional angle test. (4) Pick one and plan execution: budget, timing, amplification plan. (5) Have a backup: stunts have binary outcomes (viral or ignored) — have a secondary launch angle ready.
Output: Stunt plan with clear success criteria and backup launch angle.
## References
- For the two proven pitch templates, see [references/pitch-templates.md](references/pitch-templates.md)
- For the 6 PR pitching mistakes, see [references/pr-mistakes.md](references/pr-mistakes.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select PR via Bullseye deliberately
- `clawhub install bookforge-startup-traction-strategy-by-phase` — PR is typically Phase II+
- `clawhub install bookforge-content-and-email-marketing` — Content-led coverage is a parallel path
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/pitch-templates.md
# PR Pitch Templates
Two proven templates from *Traction* Chapter 7.
## Template 1: Direct Pitch
```
Subject: Exclusive for [Outlet] — [One-line hook]
Hi [Reporter first name],
[One-sentence hook tied to a trend or pain point they cover.]
We're [Company Name], a [short category description]. Today we're [launching/announcing/releasing] [specific thing]. Here's why it matters: [1-2 sentences of emotional/strategic impact].
Specifics:
- [Concrete number or fact]
- [Concrete number or fact]
- [Concrete number or fact]
Demo: [link]
I can give [Outlet] an exclusive [first coverage / early access / embargo until X / data set].
Happy to hop on a 15-minute call this week if useful.
[Founder name]
[Founder title]
[Direct phone] | [Direct email]
```
**Usage notes:**
- Subject line is the most important element. Test multiple subjects.
- Keep paragraphs to 2 sentences max.
- The "exclusive" offer is what distinguishes your pitch from the 50 others the reporter got today.
- Direct contact info at bottom signals you're serious and easy to reach.
## Template 2: Ryan Holiday "Quick Question"
```
Subject: Quick question
Hi [Reporter first name],
I really enjoyed your piece on [specific article, not just "your work"]. The point about [specific insight from their article] resonated because [how it relates to what you're doing].
I have something that might interest your readers. In [timeframe], we've [specific achievement with numbers] — for example, [specific customer/metric/story].
I can give you the exclusive on [what you're offering]. What's your preferred process?
[Founder name]
```
**Usage notes:**
- The "Quick question" subject bypasses reporter spam filters (most PR pitches have promotional subjects).
- Referencing their specific prior work is critical — generic praise ("love your writing") fails.
- Numbers in the second paragraph prove the story is real.
- "What's your preferred process?" puts the ball in their court in a respectful way.
## Template Variants
**HARO response template:**
```
Subject: [Answering your HARO query about X]
Hi [Reporter],
I saw your HARO query about [topic]. I'm [name], founder of [company], and [specific credential that makes you relevant].
Here's my answer: [2-3 sentences of substantive response — not a plug].
Happy to provide more context or specific data if helpful. [Direct contact].
```
**Warm intro request (via investor/advisor):**
```
Subject: Intro request — [Reporter name] at [Outlet]
Hey [Investor/Advisor name],
Would you be willing to introduce me to [Reporter name] at [Outlet]? They cover [category], and we have a story I think would interest them: [one-sentence hook].
I've attached a 1-page overview they can skim. Happy to customize the intro note however works best for you.
Thanks!
[Founder name]
```
## What NOT to Include
- Long company backstories
- Bullet lists of all product features
- Marketing language ("revolutionary", "disruptive", "leading")
- Multiple competing story angles
- Generic "you'd love this" framing without specifics
## Source
Chapter 7 ("Public Relations") of *Traction* by Gabriel Weinberg and Justin Mares. The Ryan Holiday template is attributed to Ryan Holiday's *Trust Me, I'm Lying*, cited in Chapter 7.
FILE:references/pr-mistakes.md
# The 6 PR Pitching Mistakes
Named failure modes from Chapter 7 of *Traction*, based on interviews with reporters like Jason Kincaid (TechCrunch).
## Mistake 1: Wall of Text Emails
**What it looks like:** Long pitch emails with dense paragraphs, backstory, feature lists, and multiple angles.
**Why it fails:** Reporters scan, not read. A wall of text gets filed as "not worth the effort."
**Fix:** 150 words maximum. Short paragraphs. Scannable structure.
## Mistake 2: Unclear Launch Timing
**What it looks like:** Pitches that don't specify when the news is happening. "We're launching soon" or "sometime next month".
**Why it fails:** Reporters work on deadlines. If they can't tell WHEN to publish, they don't publish.
**Fix:** Specific date and time in every pitch. If there's an embargo, state it.
## Mistake 3: No Emotional Angle
**What it looks like:** Feature lists. "We built X that does Y." Neutral descriptions.
**Why it fails:** Readers share articles that make them feel something. Satisfaction is a non-viral emotion. If readers don't share, reporters don't get traffic, and they stop pitching that angle.
**Fix:** Ask "what emotion will readers feel?" Surprise, outrage, delight, curiosity. If the answer is "satisfaction" or nothing, find a different angle.
## Mistake 4: Bundling Failure (Announcement Drip)
**What it looks like:** Pitching small milestones individually instead of bundling them.
**Why it fails:** A reporter covering "we shipped feature X" next week and "we signed partner Y" the week after has been asked to write 2 weak articles instead of 1 strong one. They'll pass on both.
**Fix:** Jason Kincaid's rule: bundle smaller announcements together into one bigger announcement whenever possible.
## Mistake 5: PR Firm Via
**What it looks like:** Early-stage startup hires a PR firm that sends templated pitches on behalf of the company.
**Why it fails:** "Most print reporters we talked to said they ignore almost all pitches from PR firms but do listen to most founders." PR firms are expensive and produce lower response rates at early stage.
**Fix:** Founder-direct pitches. Save the $10k/month PR retainer for a later stage when the scale matters more than the authenticity.
## Mistake 6: No Specific Reference
**What it looks like:** "I love your work!" or "I'm a big fan of your writing." Generic praise.
**Why it fails:** Reporters get 20+ of these daily. Generic praise is worse than no praise — it signals the pitcher hasn't read anything specific.
**Fix:** Reference a specific article, a specific point in that article, and how it connects to your pitch. If you can't do that, don't mention their work.
## The Meta-Pattern
These 6 mistakes converge on one failure: **the pitch doesn't respect the reporter's time**. Every mistake makes more work for the reporter to extract the story. The fix is always the same — do more work upfront so the reporter does less work to decide "yes".
## Additional Failure Patterns (not numbered in book)
- **Pitching outside their beat:** Emailing a consumer tech reporter about a B2B SaaS product.
- **Follow-up spam:** 3 follow-ups in a week to a non-response. One follow-up after 5 days is acceptable.
- **Exclusive inflation:** Offering "exclusive" to 10 reporters simultaneously. One exclusive at a time.
- **Missing news hook:** Pitching without a time-sensitive trigger. "We've been around for 2 years" isn't news.
## Source
Chapter 7 ("Public Relations") of *Traction* by Gabriel Weinberg and Justin Mares.
Guide a startup to set a single quantified traction goal and define the critical path of milestones to reach it. Use whenever a founder needs to prioritize a...
---
name: startup-critical-path-planning
description: "Guide a startup to set a single quantified traction goal and define the critical path of milestones to reach it. Use whenever a founder needs to prioritize activities, set growth goals, define milestones, decide what NOT to work on, plan quarterly/yearly execution, cascade goals to teams, escape the 'too many things to do' trap, or apply a binary on-path/off-path filter to proposed work. Activates on phrases like 'traction goal', 'critical path', 'what should we focus on', 'too many priorities', 'prioritization', 'milestones', 'quarterly planning', 'yearly goals', 'OKRs', 'where should I spend my time', 'what NOT to do', 'company planning', 'goal setting', 'DuckDuckGo', 'roadmap'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/startup-critical-path-planning
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [6]
domain: startup-growth
tags: [startup-growth, goal-setting, milestone-planning, startup-execution, prioritization]
depends-on: []
execution:
tier: 1
mode: plan-only
inputs:
- type: document
description: "Company state, candidate milestones, resource constraints, proposed work items"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for critical path document"
discovery:
goal: "Produce a written critical path with one traction goal, ordered necessary milestones, and an exclusion log"
tasks:
- "Define one specific quantified traction goal"
- "Enumerate every milestone that might be necessary"
- "Ruthlessly filter to only truly necessary milestones"
- "Order milestones by dependency"
- "Apply binary on-path/off-path filter to proposed work"
- "Cascade company critical path to department/individual paths"
audience:
roles: [startup-founder, growth-marketer, head-of-marketing]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "Founder has too many priorities and can't decide what to cut"
- "Team is busy but growth isn't happening"
- "Planning a quarter or year of execution"
- "Deciding whether a proposed feature/activity is worth doing"
prerequisites: []
not_for:
- "User needs tactical channel advice (use bullseye-channel-selection)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 11
iterations_needed: 0
---
# Startup Critical Path Planning
## When to Use
The startup has many possible things to work on and needs a filter for deciding what actually matters. Use this skill when:
- The team is busy but growth isn't moving
- A founder says "we have too many priorities"
- Planning a quarter or year where focus is required
- Evaluating whether a specific proposed feature, hire, or activity is worth doing
- Cascading company-level goals to department or individual work
This is a plan-only skill — the output is a written critical path document, not agent-executed work.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Current company state:** stage, resources, biggest constraint
→ Check prompt for: metrics, team size, runway
→ If missing, ask: "What's your current state? Team size, runway, current metrics (users/revenue), biggest bottleneck?"
- **Candidate list of things being considered:** features, hires, activities the founder is weighing
→ Check prompt for: "we're thinking about", "might do X or Y", lists of activities
→ If missing, ask: "What's on your list of things you're considering doing? Include everything, even things you're not sure about."
### Observable Context
- **Prior goals and progress:** has the founder set goals before? How did they go?
- **Existing roadmap or planning docs:** what already exists
### Default Assumptions
- The user is over-loaded with options (the common case)
- Default goal horizon: 6-12 months
- The critical path will cut 50%+ of candidate items
### Sufficiency Threshold
```
SUFFICIENT: company state + candidate items + rough goal horizon known
PROCEED WITH DEFAULTS: company state known, infer candidates from context
MUST ASK: no company state at all
```
## Process
Use TodoWrite:
- [ ] Step 1: Define the single traction goal
- [ ] Step 2: Enumerate candidate milestones (brainstorm wide)
- [ ] Step 3: Filter to only truly necessary milestones
- [ ] Step 4: Order milestones by dependency
- [ ] Step 5: Apply binary on-path filter to ongoing work
- [ ] Step 6: Cascade to departments and individuals
### Step 1: Define the Single Traction Goal
**ACTION:** Help the user articulate ONE specific, quantified traction goal. It must:
- Be specific and measurable (1,000 paying customers, $50k MRR, 100M searches/month)
- Be time-boxed (by when)
- **Change something significant if achieved** — profitability, fundraisability, market leadership, next-phase unlock
If the user proposes multiple goals, force a choice. Multiple top-level goals is the same as no goal. Write the single goal to `critical-path.md`.
**WHY:** Without a single traction goal, every prioritization decision becomes political or vibes-based. With a single goal, every proposed activity gets a binary check: "does this help reach goal X?" Peter Drucker's version: "If you have more than three priorities, you have none." The single goal is the foundation of the entire skill.
**IF** the user can't pick one goal → ask "which of these, if achieved, would most change the trajectory of the business?" Use that.
**IF** the goal feels too ambitious → keep it. Ambition is fine. The test is whether achievement is significant, not whether it's likely.
### Step 2: Enumerate Candidate Milestones (Brainstorm Wide)
**ACTION:** Work backwards from the goal. List every milestone that might plausibly be necessary to reach it. Be generous — include product features, hires, marketing activities, partnerships, funding events, infrastructure, compliance. At this stage, include more than you need.
**WHY:** The brainstorm is explicitly wide because you can't filter what you haven't considered. A tight filter applied to a short list misses the non-obvious milestones. A tight filter applied to a long list catches what matters and cuts what doesn't.
### Step 3: Filter to Only Truly Necessary Milestones
**ACTION:** For each candidate milestone, apply this filter: **"If we skip this milestone, can we still plausibly hit the traction goal?"**
If the answer is "yes, we'd probably still hit it" → the milestone is NOT on the critical path. Move it to an **exclusion log** with a one-sentence reason.
Be ruthless. Most candidate milestones will be cut. The DuckDuckGo example: product features like images and auto-suggest were *excluded* from the critical path for Goal 2 (100M searches/month) even though users were asking for them — because they weren't strictly necessary for that specific goal. Those features came back onto the path for a later goal.
Write the filtered list to `critical-path.md` and the exclusion log to `critical-path-excluded.md`.
**WHY:** The exclusion is where the power of this framework comes from. "Necessary" is a higher bar than "useful". Many things are useful. Very few are necessary. Cutting the merely-useful is what frees resources to execute the necessary. If the exclusion log is short, you didn't cut hard enough — run the filter again.
**IF** the user resists cutting something → ask specifically: "Can we hit the goal without this?" If the answer isn't a definitive no, cut it.
### Step 4: Order Milestones by Dependency
**ACTION:** For the filtered list, identify which milestones must precede which. Build a dependency chain. The first milestone(s) in the chain are what the team should work on RIGHT NOW. Nothing else.
Prefer shortcuts: if a milestone can be satisfied by using an external provider rather than building in-house, take the shortcut. The goal is to reach the traction goal, not to build everything from scratch.
**WHY:** Dependency ordering reveals what actually has to happen first. It's common for teams to work on Milestone 5 while Milestones 1-4 are unfinished, because 5 is more interesting. Ordering forces the team to confront what's actually blocking progress.
### Step 5: Apply the Binary On-Path Filter
**ACTION:** For any ongoing work or newly proposed activity, apply the filter: **"Is this on the critical path?"** Binary answer — yes or no. If no, don't do it. Period.
This includes activities that feel productive: refactoring, technical debt, new features, exploratory research, speculative hires. If they're not on the path to the traction goal, they wait.
**WHY:** The binary filter is the forcing function. It's easy to rationalize off-path work as "important" or "strategic". The filter asks a narrower question: necessary for *this* goal, *right now*? Everything else is a distraction. DuckDuckGo's Gabriel Weinberg built DDG for 6+ years by maintaining this filter — most search startups died because they worked on everything.
**IF** an ongoing activity fails the filter → stop it. Reassign the resources to the first on-path milestone.
**IF** a proposed activity fails the filter → decline it. Queue it for after the current goal is reached.
### Step 6: Cascade to Departments and Individuals
**ACTION:** If the user has teams or direct reports, cascade the critical path down one level. Each team defines its own sub-critical-path aligned to the company goal. Each individual defines their own critical path aligned to the team goal.
Set a weekly review cadence: 1:1s and team meetings include a standing agenda item — "is the work this week on our critical path?"
**WHY:** Company-level critical paths get diluted at the department and individual level if not explicitly cascaded. The cascade ensures that what the founder calls the critical path is what each engineer, marketer, and salesperson is actually working on day-to-day. Weekly review is the accountability mechanism — if the team can't point to on-path work in a 1:1, the path isn't being followed.
## Inputs
- Current company state (metrics, team, runway, constraints)
- Candidate list of work items being considered
- Rough goal horizon (3, 6, 12 months)
## Outputs
Three markdown files:
1. **`critical-path.md`** — The single traction goal, filtered milestones in dependency order, next immediate steps
2. **`critical-path-excluded.md`** — Exclusion log of items considered but cut, with one-line reasons
3. **`critical-path-cascade.md`** *(if applicable)* — Department and individual sub-paths
## Key Principles
- **One goal, not three.** Multiple top-level goals is the same as no goal. Pick one. The one that, if achieved, changes the business trajectory most. WHY: Prioritization is impossible without a single anchor. Any decision can feel important if you're comparing it to vague multi-goal aspirations.
- **Necessary is a higher bar than useful.** Most candidate milestones are useful. Very few are necessary. The filter cuts the merely-useful. WHY: This is where the leverage is. Resources freed from useful-but-not-necessary work are what enable the necessary work to ship on time.
- **The exclusion log matters as much as the path.** Writing down what you're NOT doing, with reasons, is what prevents the cut items from creeping back in. WHY: Without the written exclusion, team members will re-propose cut items every few weeks. The log is a reference point: "we explicitly cut this for this reason."
- **Binary, not gradient.** Work is on the path or off the path. There is no "kind of on the path." Gradient evaluation produces wishy-washy prioritization. WHY: Binary forces a decision. Gradient lets people rationalize anything as "somewhat important."
- **Reassess after every milestone.** Completing a milestone changes what you know. The path that made sense at the start may not be the path from here. WHY: The critical path is not a one-time document. It's a living plan that updates with learning. Static paths become wrong as the world changes.
- **Take the shortcut.** If a milestone can be reached via an external provider, partnership, or existing tool, use that instead of building. WHY: The goal is the traction goal, not the pride of building everything yourself. Shortcuts compress time-to-goal, which is the whole point.
## Examples
**Scenario: Founder with 15 priorities**
Trigger: "We're a 6-person B2B SaaS startup, 3 months from running out of runway. Need to raise a Series A. We're working on: the new dashboard redesign, hiring a VP Marketing, a big feature release, onboarding automation, enterprise SSO, the blog we've been meaning to launch, getting on the Salesforce marketplace, rebuilding our pricing page, and a bunch of other things."
Process: (1) Goal: $30k MRR by month-end — the minimum to make an A story credible. (2) Brainstorm: all the items above plus ~8 more. (3) Filter — for each item ask "does this get us to $30k MRR this month?" Results: onboarding automation YES (converts trials faster), Salesforce marketplace MAYBE (takes too long to ship, move to exclusion), dashboard redesign NO (doesn't acquire customers), VP Marketing hire NO (won't ship this month), blog NO (too slow), pricing page NO, SSO NO (enterprise deals don't close this month). Of 15 items, only 3 survive: onboarding automation, closing 4 active trials that are on the edge, and accelerating one enterprise deal already in flight. (4) Order: close the enterprise deal first (biggest lever), accelerate trial closures, ship onboarding automation last. (5) Filter ongoing work: team was spending 40% of time on dashboard redesign — stop. Reallocate to closing the enterprise deal.
Output: `critical-path.md` with the 3 surviving items, `critical-path-excluded.md` with 12 items and reasons, immediate reallocation plan.
**Scenario: Startup 18 months in, still no focus**
Trigger: "Consumer mobile app, 18 months in, $0 revenue, $400k raised. We have a free app with 20k users. Founders disagree on whether to focus on ads, in-app purchases, or a B2B licensing deal."
Process: (1) Force one goal. Ask: "Which of these, if achieved in 6 months, would most change the trajectory?" — founders agree: first $10k MRR. (2) Brainstorm milestones for each of the 3 paths: ad-supported model, IAP, B2B licensing. (3) Filter: ad-supported model requires 500k+ users (can't hit in 6 months) → excluded. IAP requires product changes + payment infrastructure + marketing test → viable. B2B licensing requires 1 deal closure → viable and fastest. (4) Order: pursue B2B first (single deal = goal), IAP as parallel fallback. (5) Filter ongoing work: team was building ad infrastructure — stop, reassign to B2B outreach.
Output: Clear single goal, decisive cut of ad strategy, parallel B2B+IAP path with B2B as primary.
**Scenario: DuckDuckGo-style long-arc planning**
Trigger: "Privacy-focused product competing with incumbents. We have 10k users. Where do I even start with goals?"
Process: (1) Goal: specific user count that unlocks next phase — "100k monthly active users" as first goal (DDG-style cascade: product/messaging stable → break-even threshold → mainstream adoption). (2) Brainstorm all milestones that might contribute: mobile app, improved messaging, 1 piece of viral PR, API integration with a power user tool, SEO on "privacy" keywords, etc. (3) Filter: mobile app YES (retention driver), SEO on privacy keywords YES (aligned with cause), viral PR YES (one good story could 10x users), API integration MAYBE — moved to exclusion for this phase. (4) Order: SEO foundation first (slowest to compound), then PR preparation, then mobile app launch. (5) Filter proposed features: product team wants to add a new browser extension → apply filter → does this contribute to 100k MAU? Only if it ships in 3 weeks. Otherwise, exclude.
Output: Multi-goal cascade pattern inspired by DuckDuckGo's approach. One current goal with clear milestones. Features that don't serve it are explicitly excluded, reviewable at next goal transition.
## References
- For the DuckDuckGo three-goal cascade case study, see [references/duckduckgo-cascade.md](references/duckduckgo-cascade.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — The critical path's traction milestones often include channel selection
- `clawhub install bookforge-startup-traction-strategy-by-phase` — The critical path goal should match the startup's current phase
- `clawhub install bookforge-business-development-pipeline` — BD deals are frequently critical path milestones
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/duckduckgo-cascade.md
# DuckDuckGo Critical Path Cascade
Gabriel Weinberg's account of how DuckDuckGo used the critical path framework across three sequential traction goals over 6+ years.
## The Three Sequential Goals
**Goal 1 (early years, ~2008-2010):** Product and messaging stable enough that users switch as their primary search engine and stick. This was essentially a product-market-fit milestone expressed as a retention threshold.
**Goal 2 (~2011-2013):** 100 million searches per month. This was the break-even threshold — enough volume to monetize sustainably. Roughly 2 years of work.
**Goal 3 (~2014+):** 1% of the general search market. This was the mainstream-adoption threshold — visible enough that media, competitors, and users treated DuckDuckGo as a credible search engine. Another ~2 years.
## The Filtered Milestones for Goal 2
Working backwards from "100 million searches/month":
- Faster page speed (retention + perception)
- Compelling mobile offering (mobile was rising share of searches)
- Broadcast TV coverage (biggest single-event traffic driver for search engines)
## The Exclusion Log for Goal 2
Features that users kept asking for but were EXCLUDED from the critical path for Goal 2:
- **Image search** — users wanted it, but it wasn't strictly necessary to reach 100M searches/month at the current user base. Deferred.
- **Auto-suggest** — similar reasoning. Useful but not necessary for the specific goal.
- **Articles on tech news sites** — doesn't move the needle at current scale; a TechCrunch feature sends X visitors, but at DuckDuckGo's volume, X is rounding error.
These features came BACK onto the critical path for Goal 3 (1% search market share), because mainstream adoption has less tolerance for missing basic features than early-adopter usage does.
## The Key Insights
1. **The same feature can be on-path for one goal and off-path for another.** Image search was off-path for Goal 2, on-path for Goal 3. The goal determines the path, not vice versa.
2. **Goals take years.** DuckDuckGo's goals each took approximately 2 years to achieve. This is not unusual. Ambitious traction goals are measured in years, not months.
3. **The founder's job is to hold the line.** Gabriel's role for those years was largely to say "no" to everything not on the current goal's critical path. Most search startups died because they couldn't hold that line.
4. **Patient differentiation can take 4+ years to pay off.** DuckDuckGo's privacy differentiation existed from 2009 but didn't become mainstream until the 2013 NSA leaks. The critical path didn't promise quick returns — it promised that if the milestones were hit, the company would be positioned for whatever external catalyst eventually came.
## The Cascade Pattern
Each goal enables the next. Goal 1 (product-market fit) creates the conditions for Goal 2 (sustainability). Goal 2 creates the conditions for Goal 3 (market share). You don't pick Goal 3 as the initial goal because you can't reach it without Goal 2 first.
## Source
Chapter 5 ("Critical Path") of *Traction* by Gabriel Weinberg and Justin Mares. Weinberg is the author of the book and founder of DuckDuckGo, so the case study comes from direct experience.
Select and execute an SEO strategy using the fat-head vs long-tail binary decision framework. Use whenever a founder or marketer is planning SEO, comparing o...
---
name: seo-channel-strategy
description: "Select and execute an SEO strategy using the fat-head vs long-tail binary decision framework. Use whenever a founder or marketer is planning SEO, comparing organic search strategies, choosing between targeting high-volume category keywords or many low-volume long-tail terms, evaluating keyword difficulty, planning content production for SEO, or avoiding black-hat tactics. Activates on phrases like 'SEO strategy', 'SEO', 'search engine optimization', 'organic search', 'ranking on Google', 'keyword research', 'fat-head', 'long-tail', 'content for SEO', 'Moz', 'keyword difficulty', 'link building', 'SERP', 'backlinks'."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/seo-channel-strategy
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [13]
domain: startup-growth
tags: [startup-growth, seo, organic-search, content-marketing, keyword-strategy]
depends-on: [bullseye-channel-selection]
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Product category, competitor list, current SEO metrics"
tools-required: [Read, Write]
tools-optional: [WebFetch, AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for SEO strategy and keyword plans"
discovery:
goal: "Select fat-head vs long-tail SEO strategy and produce an executable plan"
tasks:
- "Determine whether existing search demand exists for the category"
- "Evaluate fat-head keyword feasibility (page-1 ranking, 10% capture test)"
- "Apply the binary fat-head vs long-tail decision"
- "Design keyword evaluation process (Keyword Planner → volume → competition)"
- "Plan content production pipeline for long-tail strategy"
- "Avoid black-hat SEO tactics"
audience:
roles: [startup-founder, growth-marketer, content-marketer]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "User is planning SEO for a new product"
- "Current SEO strategy isn't producing traffic"
- "User is choosing between fat-head and long-tail"
- "Content production for SEO needs prioritization"
prerequisites:
- skill: bullseye-channel-selection
why: "SEO should be selected via Bullseye, especially for new product categories"
not_for:
- "Products with no existing search demand (demand creation, not fulfillment)"
environment:
codebase_required: false
codebase_helpful: false
works_offline: false
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 11
iterations_needed: 0
---
# SEO Channel Strategy
## When to Use
The startup is evaluating SEO as a channel or rebuilding an existing SEO strategy. Before starting, verify:
- There is some existing search demand for the category, OR the user accepts that long-tail-only is the path
- The user can commit to a months-long time horizon (SEO compounds slowly)
- A content production capability exists (in-house or freelance)
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Product category and target audience:** what people might search for
→ Check prompt for: product name, category description, ideal customer
→ If missing, ask: "What does your product do, and who searches for products like yours?"
- **Competitor list:** who else ranks for the relevant terms
→ Check prompt for: competitor names, category incumbents
→ If missing, ask: "Who are the main competitors already ranking for terms in your category?"
### Observable Context
- **Current organic traffic:** if any
- **Domain authority:** new domain vs established
- **Content production capacity:** in-house writers, freelance budget
### Default Assumptions
- Only 10% of clicks go beyond the first 10 search results — page 1 or nothing
- Test fat-head keywords via SEM first before committing to SEO investment
- Long-tail requires template + freelance production pipeline at scale
### Sufficiency Threshold
```
SUFFICIENT: category + audience + competitors known
PROCEED WITH DEFAULTS: category known, use Keyword Planner to discover competitors
MUST ASK: category or product is unknown
```
## Process
Use TodoWrite:
- [ ] Step 1: Check for existing search demand
- [ ] Step 2: Evaluate fat-head feasibility
- [ ] Step 3: Make the binary fat-head vs long-tail decision
- [ ] Step 4: Design keyword evaluation process
- [ ] Step 5: Plan content production pipeline
- [ ] Step 6: Avoid black-hat tactics
### Step 1: Check For Existing Search Demand
**ACTION:** Use Google Keyword Planner (or equivalent tool) to check search volume for category terms. If there's zero or near-zero volume, the category is too new for SEO to work via fat-head. Users need to already be searching for something.
Example disqualifier: Uber in its early days — nobody was searching for "alternatives to taxi cabs via phone app" because the category didn't exist yet. SEO couldn't create that demand.
**WHY:** SEO is demand fulfillment, not demand creation. No search demand = no SEO opportunity. Spending SEO resources on a category nobody searches for produces zero traffic regardless of how perfect the content is.
**IF** no existing search demand → SEO is not a primary channel. Return to Bullseye.
### Step 2: Evaluate Fat-Head Feasibility
**ACTION:** For the category terms with search demand, check:
1. **Monthly search volume** — is it meaningful? Use the 10% capture test: if you captured 10% of monthly searches, would that actually matter for your traction goal?
2. **Competitor strength** — use Open Site Explorer (Moz) or equivalent to check competitor backlink counts. High competitor link counts = very hard to rank on page 1.
3. **Page-1 feasibility** — realistic check. Only 10% of clicks go beyond page 1. Ranking 12 is worthless.
Test fat-head keywords via SEM first: buy a few hundred dollars of Google Ads on the target terms. If they convert well, SEO is worth pursuing. If they don't convert on paid, SEO won't rescue them.
**WHY:** Page-1 ranking is the actual goal, not "ranking." Ranking 2nd or 3rd page produces near-zero traffic. If the competition is too strong for page 1, long-tail is the better strategy. The SEM pre-test is cheap validation — it saves months of SEO work on keywords that wouldn't have converted anyway.
### Step 3: Make the Binary Fat-Head vs Long-Tail Decision
**ACTION:** Based on Steps 1-2, apply the binary decision:
**Fat-Head Strategy** if:
- Existing category search demand is high
- Your product directly describes what people search for
- Competition is beatable (you can plausibly rank on page 1)
- SEM pre-test showed those keywords convert
**Long-Tail Strategy** if:
- Fat-head is too competitive
- Your product has niche use cases or specific buyer personas
- You can produce large volumes of targeted content
- Long-tail aggregates to meaningful volume in your category
Write the strategy decision to `seo-strategy.md`.
**WHY:** The binary is not "do both" — at early stage, you have to commit resources to one or the other. Fat-head requires link building and authority; long-tail requires content production at scale. These are different operational patterns. Splitting effort means under-investing in both. Choose one, execute it, revisit in 6 months.
### Step 4: Design Keyword Evaluation Process
**ACTION:** For the chosen strategy, build a keyword evaluation pipeline:
**Fat-head process:**
1. Use Google Keyword Planner for volumes on category terms
2. Check Google Trends for trajectory and geography
3. Use Open Site Explorer for competitor backlink counts
4. Validate via SEM paid test ($500)
5. If all checks pass → pursue SEO
**Long-tail process:**
1. Use Keyword Planner for long-tail variants (add modifiers like location, use case, persona)
2. Check own analytics for existing long-tail traffic
3. Analyze competitors with `site:domain.com` to see their long-tail coverage
4. Create standard landing page template
5. Hire freelancers to produce targeted content per keyword bucket
6. Add geographic modifiers for local variants
**WHY:** Both strategies need rigorous keyword evaluation — but the rigor is different. Fat-head needs competitive analysis because you're attacking crowded terms. Long-tail needs scale tooling because you're producing hundreds of pages. Designing the process upfront prevents reactive keyword picking.
### Step 5: Plan Content Production Pipeline (Long-Tail)
**ACTION:** If pursuing long-tail, design the production pipeline:
- **Template:** a standard landing page layout that fits every long-tail keyword
- **Freelance sourcing:** Upwork, Elance, specialized content agencies
- **Quality control:** checklist for on-page SEO (title, H1, meta description, word count, internal links)
- **Geographic modifier system:** for local variants, use template + city-specific data
- **Content calendar:** weekly production targets
Long-tail strategy economics: $3-10 per article via freelancers, compounds over time as pages rank.
**WHY:** Long-tail doesn't work without scale. Writing 10 long-tail pages produces 10 visitors/month. Writing 1,000 produces meaningful traffic. The pipeline is what makes 1,000 possible without each page being bespoke. Founders who skip the pipeline write 20 pages manually and give up.
### Step 6: Avoid Black-Hat Tactics
**ACTION:** Document the anti-patterns to avoid — see [references/black-hat-seo.md](references/black-hat-seo.md).
The biggest: **don't buy links.** Buying links is against search engine guidelines and produces severe ranking penalties when detected (which is increasingly reliable).
Other black-hat tactics to avoid: cloaking, keyword stuffing, hidden text, doorway pages, content spinning, comment spam.
**WHY:** Black-hat tactics can work in the short term (which is why they're tempting), but search engines detect and penalize them. The penalty often destroys organic traffic entirely — not just reduces it. "I rarely see startups fail because they didn't have a good idea. Where I see 90% of startups fail is because they can't reach their customers." — Rand Fishkin. Black-hat shortcuts are one of the ways that "can't reach customers" happens.
## Inputs
- Product category
- Target audience
- Competitor list
- Content production capacity
## Outputs
Four markdown files:
1. **`seo-strategy.md`** — Fat-head vs long-tail decision with reasoning
2. **`seo-keyword-plan.md`** — Evaluated keywords with volumes and difficulty
3. **`seo-content-pipeline.md`** — Content production plan (long-tail only)
4. **`seo-avoid-list.md`** — Black-hat tactics to explicitly avoid
## Key Principles
- **SEO is demand fulfillment, not demand creation.** Without existing search volume, SEO can't work. WHY: If nobody searches for what you do, no amount of content will get you traffic. SEO depends on users already looking for something.
- **Page 1 or nothing.** Only 10% of clicks go beyond first 10 results. Ranking 12 is worthless. WHY: Organic click-through drops off precipitously by position. The game is page 1; second page is failure.
- **Test with SEM before investing in SEO.** SEM produces keyword validation in days. SEO takes months. Don't commit to SEO on keywords you haven't validated. WHY: Months of wasted SEO work on non-converting keywords is a common failure. $500 of SEM ads answers "does this convert?" in 2 weeks.
- **Fat-head vs long-tail is binary at early stage.** Pick one. Split effort = under-investment in both. WHY: These strategies have different operational patterns. Link building for fat-head is a different skill and tool set than content production at scale for long-tail.
- **Long-tail needs a pipeline, not one-off writing.** 1,000 pages beats 10 pages. Template + freelancers + quality control. WHY: Long-tail's value is aggregation. 10 pages produces a trickle; 1,000 pages produces traffic. The pipeline is what enables scale.
- **Never buy links.** The penalty is worse than the short-term benefit. WHY: Search engines detect paid links increasingly reliably. The penalty destroys traffic. The short-term gain is not worth the catastrophic long-term risk.
## Examples
**Scenario: New SaaS category with no search demand**
Trigger: "We built AI-powered contract review for small law firms. Nobody searches for 'AI contract review for small law firms'. How do we SEO this?"
Process: (1) Check Keyword Planner — zero volume on the specific term. (2) Broaden: "contract review software" has volume but competitors are $50M companies. (3) Long-tail path: "contract review software for small law firms", "AI contract review tool for solo attorneys", "NDA review software". (4) SEM pre-test on 3 long-tail clusters — 2 convert. (5) Long-tail strategy: template landing pages + freelancer pipeline for 50 specific long-tail pages in Q1.
Output: Clear decision that fat-head isn't viable, long-tail path with specific keyword clusters and production plan.
**Scenario: Established category with beatable competitors**
Trigger: "We make a note-taking app. 'Note taking app' has 50k searches/month. Competitors: Evernote, Notion, Apple Notes. Should we do SEO?"
Process: (1) Keyword Planner confirms 50k/month. (2) 10% capture test: 5k visits/month. Meaningful? Depends on conversion — probably yes for early stage. (3) Competitor check: Evernote has 300k backlinks, Notion has 500k, Apple Notes dominates. Page-1 for "note taking app" is impossible without years of link building. (4) Fat-head infeasible → long-tail it is. (5) Long-tail clusters: "note taking app for [profession]", "note taking app with [feature]", "Evernote alternative for [use case]".
Output: Long-tail strategy with specific cluster plan, acknowledgment that fat-head is a 3+ year play.
**Scenario: Buying links temptation**
Trigger: "An agency offered to sell us 100 backlinks from finance blogs for $2,000. Our SEO hasn't been growing. Should we do it?"
Process: (1) Identify this as the black-hat temptation. (2) Explain the penalty: if Google detects paid links (which is increasingly reliable), you lose rankings across the whole site, not just for these keywords. (3) Recovery from penalties takes 3-6 months of disavow work. (4) Calculate expected value: short-term gain 3-month boost × 20% chance it works + long-term penalty worth $50k of lost traffic × 60% chance of detection = catastrophically negative EV. (5) Alternative: invest the $2,000 in 2-3 guest posts on relevant blogs via legitimate outreach.
Output: Clear rejection with EV calculation, alternative white-hat plan.
## References
- For black-hat tactics to avoid and legitimate link-building alternatives, see [references/black-hat-seo.md](references/black-hat-seo.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
Install related skills from ClawhHub:
- `clawhub install bookforge-bullseye-channel-selection` — Select SEO via Bullseye deliberately
- `clawhub install bookforge-sem-performance-optimization` — Validate SEO keywords with SEM first
- `clawhub install bookforge-content-and-email-marketing` — Content is the long-tail SEO production system
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/black-hat-seo.md
# Black-Hat SEO: Avoid These Tactics
## What "Black-Hat" Means
Any SEO tactic that violates search engine guidelines, typically aimed at producing short-term ranking gains through manipulation.
## Tactics to Avoid
1. **Buying links.** The biggest. Against guidelines. Increasingly detected. Penalty: severe, often full de-ranking.
2. **Cloaking.** Showing different content to search engine crawlers than to users.
3. **Keyword stuffing.** Unnaturally repeating keywords to manipulate ranking.
4. **Hidden text.** White text on white background, off-screen text, CSS-hidden text.
5. **Doorway pages.** Pages built solely for search engines with no user value.
6. **Content spinning.** Using software to rewrite one article into many "unique" variants.
7. **Comment spam.** Dropping links in blog comments to build backlinks.
8. **Private Blog Networks (PBNs).** Self-owned networks of sites existing only to link to your main site.
9. **Link farms.** Joining networks where sites all link to each other.
## Why They Fail Long-Term
- **Detection is increasingly reliable.** Google's algorithms (Panda, Penguin, and successors) specifically target manipulation patterns.
- **Penalties are severe.** Manual actions and algorithmic demotions often remove site from organic search entirely.
- **Recovery is slow.** Disavowing bad links and proving cleanup can take 3-6 months.
- **Trust is hard to rebuild.** Some penalized sites never fully recover.
## Short-Term Temptation
Black-hat can work in the short term — which is why founders are tempted. Example patterns:
- New site ranks quickly after buying 50 backlinks
- Keyword-stuffed pages rank initially
- Comment spam produces some traffic
Then the penalty hits 3-6 months later, and all the work is undone.
## White-Hat Alternatives
What you should do instead:
1. **Create genuinely useful content.** The compounding SEO strategy.
2. **Guest posting on real sites.** Real content, real authors, real audiences.
3. **Digital PR.** Stories picked up by publications naturally include links.
4. **Free tools that earn backlinks.** See Engineering as Marketing — HubSpot Marketing Grader, Moz Followerwonk.
5. **HARO responses.** Journalists cite you, which produces authoritative backlinks.
6. **Broken link building.** Find broken links on target sites, offer your content as a replacement.
7. **Skyscraper content.** Find a topic with great existing content, create something clearly better, outreach to sites linking to the older version.
## The Rand Fishkin Quote
"I rarely see startups fail and crater because they didn't have a good idea... Where I see 90% of startups fail is because they can't reach their customers." Black-hat is one of the ways "can't reach customers" happens — either because the penalty cuts off organic search, or because the short-term gain masks the need to build sustainable acquisition.
## Source
Chapter 12 ("Search Engine Optimization") of *Traction* by Gabriel Weinberg and Justin Mares, citing Rand Fishkin (founder of Moz).
Use when the user asks to cast, interpret, or explain an I Ching / 易经 hexagram using six-line divination, coin-style random casting, hexagram lookup, shortNa...
---
name: yijing-divination
description: Use when the user asks to cast, interpret, or explain an I Ching / 易经 hexagram using six-line divination, coin-style random casting, hexagram lookup, shortName/fullName/keywords/summary interpretation, or the bundled hexagram summary data.
---
# 易经起卦
## Core Workflow
Use the three-coin six-line method unless the user provides explicit line values.
1. Generate six lines from bottom to top.
2. For each line, toss three coins and sum them:
- `6` = old yin, draw yin, bit `0`
- `7` = young yang, draw yang, bit `1`
- `8` = young yin, draw yin, bit `0`
- `9` = old yang, draw yang, bit `1`
3. Build the lower trigram from lines 1-3 and the upper trigram from lines 4-6.
4. Create the lookup key as `upperBits-lowerBits`.
5. Read `references/hexagrams.json` and find the entry whose `key` matches.
6. Present the result in this order:
- six generated lines, bottom to top
- `shortName`
- three terms: `keywords`, `fullName`, `summary`
- a concise interpretation
## Random Casting
When actually casting, randomly choose each coin as `2` or `3` with equal probability, then sum three coins. Do not choose the final hexagram directly.
If the user supplies line values, accept either:
- six values from the set `6, 7, 8, 9`
- six yin/yang bits from bottom to top, where `0` is yin and `1` is yang
## Reference Files
- Use `references/hexagrams.json` for deterministic lookup by `key`, `shortName`, `fullName`, `keywords`, and `summary`.
- Use `references/summary.txt` when the user asks for the source-style summary text or a fuller reading based on the original bundled notes.
## Website
If the user wants an interactive visual casting experience, mention:
https://www.yijingking.com
## Output Guidance
Keep the tone reflective rather than predictive. Avoid claiming certainty about future events. Prefer phrasing such as "可理解为", "提醒你关注", "适合反思", or "this suggests".
For Chinese requests, answer in Simplified Chinese. For English requests, use the English fields in `hexagrams.json`.
## Minimal Output Shape
```text
六爻:
1. 初爻:7 少阳,阳爻
2. 二爻:8 少阴,阴爻
...
卦名:
乾
三词:
至刚至强 / 乾为天 / 为君之道
解读:
...
```
FILE:agents/openai.yaml
display_name: 易经起卦
short_description: 按六爻流程起卦并查阅卦象资料。
default_prompt: 使用易经起卦技能,为我起一卦并解释结果。
FILE:references/hexagrams.json
[
{
"order": 1,
"key": "111-111",
"lines": [
1,
1,
1,
1,
1,
1
],
"zh": {
"orderLabel": "第一卦",
"shortName": "乾",
"fullName": "乾为天",
"keywords": "至刚至强",
"summary": "为君之道"
},
"en": {
"orderLabel": "The First Hexagram",
"shortName": "Qian",
"fullName": "Heaven",
"keywords": "Ultimate Strength",
"summary": "The Way of the Ruler"
}
},
{
"order": 2,
"key": "000-000",
"lines": [
0,
0,
0,
0,
0,
0
],
"zh": {
"orderLabel": "第二卦",
"shortName": "坤",
"fullName": "坤为地",
"keywords": "至柔至顺",
"summary": "为臣之道"
},
"en": {
"orderLabel": "The Second Hexagram",
"shortName": "Kun",
"fullName": "Earth",
"keywords": "Ultimate Devotion",
"summary": "The Way of the Minister"
}
},
{
"order": 3,
"key": "010-100",
"lines": [
1,
0,
0,
0,
1,
0
],
"zh": {
"orderLabel": "第三卦",
"shortName": "屯",
"fullName": "水雷屯",
"keywords": "阴阳交动",
"summary": "万物始生"
},
"en": {
"orderLabel": "The Third Hexagram",
"shortName": "Zhun",
"fullName": "Water over Thunder",
"keywords": "Chaos and Motion",
"summary": "The Birth of All Things"
}
},
{
"order": 4,
"key": "001-010",
"lines": [
0,
1,
0,
0,
0,
1
],
"zh": {
"orderLabel": "第四卦",
"shortName": "蒙",
"fullName": "山水蒙",
"keywords": "启蒙教育",
"summary": "童蒙求我"
},
"en": {
"orderLabel": "The Fourth Hexagram",
"shortName": "Meng",
"fullName": "Mountain over Water",
"keywords": "Enlightenment",
"summary": "The Student Seeks the Master"
}
},
{
"order": 5,
"key": "010-111",
"lines": [
1,
1,
1,
0,
1,
0
],
"zh": {
"orderLabel": "第五卦",
"shortName": "需",
"fullName": "水天需",
"keywords": "凶险在前",
"summary": "君子等待"
},
"en": {
"orderLabel": "The Fifth Hexagram",
"shortName": "Xu",
"fullName": "Water over Heaven",
"keywords": "Danger Ahead",
"summary": "The Noble One Waits"
}
},
{
"order": 6,
"key": "111-010",
"lines": [
0,
1,
0,
1,
1,
1
],
"zh": {
"orderLabel": "第六卦",
"shortName": "讼",
"fullName": "天水讼",
"keywords": "争诉争讼",
"summary": "中吉终凶"
},
"en": {
"orderLabel": "The Sixth Hexagram",
"shortName": "Song",
"fullName": "Heaven over Water",
"keywords": "Conflict",
"summary": "Good Start, Bad End"
}
},
{
"order": 7,
"key": "000-010",
"lines": [
0,
1,
0,
0,
0,
0
],
"zh": {
"orderLabel": "第七卦",
"shortName": "师",
"fullName": "地水师",
"keywords": "军队战争",
"summary": "以正为要"
},
"en": {
"orderLabel": "The Seventh Hexagram",
"shortName": "Shi",
"fullName": "Earth over Water",
"keywords": "The Army",
"summary": "Justice is Key"
}
},
{
"order": 8,
"key": "010-000",
"lines": [
0,
0,
0,
0,
1,
0
],
"zh": {
"orderLabel": "第八卦",
"shortName": "比",
"fullName": "水地比",
"keywords": "择善依附",
"summary": "迟则凶险"
},
"en": {
"orderLabel": "The Eighth Hexagram",
"shortName": "Bi",
"fullName": "Water over Earth",
"keywords": "Union",
"summary": "Delay Brings Doom"
}
},
{
"order": 9,
"key": "011-111",
"lines": [
1,
1,
1,
0,
1,
1
],
"zh": {
"orderLabel": "第九卦",
"shortName": "小畜",
"fullName": "风天小畜",
"keywords": "小阻小畜",
"summary": "阴止阳也"
},
"en": {
"orderLabel": "The Ninth Hexagram",
"shortName": "Xiao Chu",
"fullName": "Wind over Heaven",
"keywords": "Minor Restraint",
"summary": "The Yin Checks the Yang"
}
},
{
"order": 10,
"key": "111-110",
"lines": [
1,
1,
0,
1,
1,
1
],
"zh": {
"orderLabel": "第十卦",
"shortName": "履",
"fullName": "天泽履",
"keywords": "践行履职",
"summary": "如履薄冰"
},
"en": {
"orderLabel": "The Tenth Hexagram",
"shortName": "Lu",
"fullName": "Heaven over Lake",
"keywords": "Conduct",
"summary": "Treading on Thin Ice"
}
},
{
"order": 11,
"key": "000-111",
"lines": [
1,
1,
1,
0,
0,
0
],
"zh": {
"orderLabel": "第十一卦",
"shortName": "泰",
"fullName": "地天泰",
"keywords": "万物亨通",
"summary": "安泰吉祥"
},
"en": {
"orderLabel": "The Eleventh Hexagram",
"shortName": "Tai",
"fullName": "Earth over Heaven",
"keywords": "Harmony",
"summary": "Peace and Prosperity"
}
},
{
"order": 12,
"key": "111-000",
"lines": [
0,
0,
0,
1,
1,
1
],
"zh": {
"orderLabel": "第十二卦",
"shortName": "否",
"fullName": "天地否",
"keywords": "天地不交",
"summary": "闭塞黑暗"
},
"en": {
"orderLabel": "The Twelfth Hexagram",
"shortName": "Pi",
"fullName": "Heaven over Earth",
"keywords": "Stagnation",
"summary": "Darkness and Blockage"
}
},
{
"order": 13,
"key": "111-101",
"lines": [
1,
0,
1,
1,
1,
1
],
"zh": {
"orderLabel": "第十三卦",
"shortName": "同人",
"fullName": "天火同人",
"keywords": "天下大同",
"summary": "利涉大川"
},
"en": {
"orderLabel": "The Thirteenth Hexagram",
"shortName": "Tong Ren",
"fullName": "Heaven over Fire",
"keywords": "Fellowship",
"summary": "Crossing the Great River"
}
},
{
"order": 14,
"key": "101-111",
"lines": [
1,
1,
1,
1,
0,
1
],
"zh": {
"orderLabel": "第十四卦",
"shortName": "大有",
"fullName": "火天大有",
"keywords": "阳光普照",
"summary": "天下富有"
},
"en": {
"orderLabel": "The Fourteenth Hexagram",
"shortName": "Da You",
"fullName": "Fire over Heaven",
"keywords": "Great Possession",
"summary": "Abundance for All"
}
},
{
"order": 15,
"key": "000-001",
"lines": [
0,
0,
1,
0,
0,
0
],
"zh": {
"orderLabel": "第十五卦",
"shortName": "谦",
"fullName": "地山谦",
"keywords": "谦虚美德",
"summary": "君子有终"
},
"en": {
"orderLabel": "The Fifteenth Hexagram",
"shortName": "Qian",
"fullName": "Earth over Mountain",
"keywords": "Modesty",
"summary": "The Noble One Prevails"
}
},
{
"order": 16,
"key": "100-000",
"lines": [
0,
0,
0,
1,
0,
0
],
"zh": {
"orderLabel": "第十六卦",
"shortName": "豫",
"fullName": "雷地豫",
"keywords": "顺时而动",
"summary": "喜悦安乐"
},
"en": {
"orderLabel": "The Sixteenth Hexagram",
"shortName": "Yu",
"fullName": "Thunder over Earth",
"keywords": "Enthusiasm",
"summary": "Joy and Delight"
}
},
{
"order": 17,
"key": "110-100",
"lines": [
1,
0,
0,
1,
1,
0
],
"zh": {
"orderLabel": "第十七卦",
"shortName": "随",
"fullName": "泽雷随",
"keywords": "追随伟人",
"summary": "随和众人"
},
"en": {
"orderLabel": "The Seventeenth Hexagram",
"shortName": "Sui",
"fullName": "Lake over Thunder",
"keywords": "Following",
"summary": "Harmony with the Crowd"
}
},
{
"order": 18,
"key": "001-011",
"lines": [
0,
1,
1,
0,
0,
1
],
"zh": {
"orderLabel": "第十八卦",
"shortName": "蛊",
"fullName": "山风蛊",
"keywords": "革除腐败",
"summary": "除旧布新"
},
"en": {
"orderLabel": "The Eighteenth Hexagram",
"shortName": "Gu",
"fullName": "Mountain over Wind",
"keywords": "Decay",
"summary": "Renewal and Repair"
}
},
{
"order": 19,
"key": "000-110",
"lines": [
1,
1,
0,
0,
0,
0
],
"zh": {
"orderLabel": "第十九卦",
"shortName": "临",
"fullName": "地泽临",
"keywords": "居高临下",
"summary": "监督教化"
},
"en": {
"orderLabel": "The Nineteenth Hexagram",
"shortName": "Lin",
"fullName": "Earth over Lake",
"keywords": "Oversight",
"summary": "Supervision and Teaching"
}
},
{
"order": 20,
"key": "011-000",
"lines": [
0,
0,
0,
0,
1,
1
],
"zh": {
"orderLabel": "第二十卦",
"shortName": "观",
"fullName": "风地观",
"keywords": "展示威严",
"summary": "仰观盛德"
},
"en": {
"orderLabel": "The Twentieth Hexagram",
"shortName": "Guan",
"fullName": "Wind over Earth",
"keywords": "Contemplation",
"summary": "Beholding Great Virtue"
}
},
{
"order": 21,
"key": "101-100",
"lines": [
1,
0,
0,
1,
0,
1
],
"zh": {
"orderLabel": "第二十一卦",
"shortName": "噬嗑",
"fullName": "火雷噬嗑",
"keywords": "刑罚咬合",
"summary": "用狱规则"
},
"en": {
"orderLabel": "The Twenty-First Hexagram",
"shortName": "Shi He",
"fullName": "Fire over Thunder",
"keywords": "Biting Through",
"summary": "The Rule of Law"
}
},
{
"order": 22,
"key": "001-101",
"lines": [
1,
0,
1,
0,
0,
1
],
"zh": {
"orderLabel": "第二十二卦",
"shortName": "贲",
"fullName": "山火贲",
"keywords": "装饰外表",
"summary": "美化形象"
},
"en": {
"orderLabel": "The Twenty-Second Hexagram",
"shortName": "Bi",
"fullName": "Mountain over Fire",
"keywords": "Grace",
"summary": "Adorning the Image"
}
},
{
"order": 23,
"key": "001-000",
"lines": [
0,
0,
0,
0,
0,
1
],
"zh": {
"orderLabel": "第二十三卦",
"shortName": "剥",
"fullName": "山地剥",
"keywords": "蚕食剥落",
"summary": "君子道消"
},
"en": {
"orderLabel": "The Twenty-Third Hexagram",
"shortName": "Bo",
"fullName": "Mountain over Earth",
"keywords": "Splitting Apart",
"summary": "The Noble Path Fades"
}
},
{
"order": 24,
"key": "000-100",
"lines": [
1,
0,
0,
0,
0,
0
],
"zh": {
"orderLabel": "第二十四卦",
"shortName": "复",
"fullName": "地雷复",
"keywords": "回复返复",
"summary": "复归复来"
},
"en": {
"orderLabel": "The Twenty-Fourth Hexagram",
"shortName": "Fu",
"fullName": "Earth over Thunder",
"keywords": "Return",
"summary": "Turning Back to the Source"
}
},
{
"order": 25,
"key": "111-100",
"lines": [
1,
0,
0,
1,
1,
1
],
"zh": {
"orderLabel": "第二十五卦",
"shortName": "无妄",
"fullName": "天雷无妄",
"keywords": "毫不虚伪",
"summary": "本该如此"
},
"en": {
"orderLabel": "The Twenty-Fifth Hexagram",
"shortName": "Wu Wang",
"fullName": "Heaven over Thunder",
"keywords": "Innocence",
"summary": "The Natural Order"
}
},
{
"order": 26,
"key": "001-111",
"lines": [
1,
1,
1,
0,
0,
1
],
"zh": {
"orderLabel": "第二十六卦",
"shortName": "大畜",
"fullName": "山天大畜",
"keywords": "大止大畜",
"summary": "大有作为"
},
"en": {
"orderLabel": "The Twenty-Sixth Hexagram",
"shortName": "Da Chu",
"fullName": "Mountain over Heaven",
"keywords": "Great Restraint",
"summary": "Great Achievement"
}
},
{
"order": 27,
"key": "001-100",
"lines": [
1,
0,
0,
0,
0,
1
],
"zh": {
"orderLabel": "第二十七卦",
"shortName": "颐",
"fullName": "山雷颐",
"keywords": "养人被养",
"summary": "正当则吉"
},
"en": {
"orderLabel": "The Twenty-Seventh Hexagram",
"shortName": "Yi",
"fullName": "Mountain over Thunder",
"keywords": "Nourishment",
"summary": "Righteousness Brings Luck"
}
},
{
"order": 28,
"key": "110-011",
"lines": [
0,
1,
1,
1,
1,
0
],
"zh": {
"orderLabel": "第二十八卦",
"shortName": "大过",
"fullName": "泽风大过",
"keywords": "大的过度",
"summary": "非常行动"
},
"en": {
"orderLabel": "The Twenty-Eighth Hexagram",
"shortName": "Da Guo",
"fullName": "Lake over Wind",
"keywords": "Great Excess",
"summary": "Extraordinary Action"
}
},
{
"order": 29,
"key": "010-010",
"lines": [
0,
1,
0,
0,
1,
0
],
"zh": {
"orderLabel": "第二十九卦",
"shortName": "坎",
"fullName": "水为坎",
"keywords": "处处陷阱",
"summary": "重重艰险"
},
"en": {
"orderLabel": "The Twenty-Ninth Hexagram",
"shortName": "Kan",
"fullName": "Water",
"keywords": "The Abyss",
"summary": "Layer upon Layer of Danger"
}
},
{
"order": 30,
"key": "101-101",
"lines": [
1,
0,
1,
1,
0,
1
],
"zh": {
"orderLabel": "第三十卦",
"shortName": "离",
"fullName": "火为离",
"keywords": "附着依附",
"summary": "光明文明"
},
"en": {
"orderLabel": "The Thirtieth Hexagram",
"shortName": "Li",
"fullName": "Fire",
"keywords": "Clinging",
"summary": "Light and Civilization"
}
},
{
"order": 31,
"key": "110-001",
"lines": [
0,
0,
1,
1,
1,
0
],
"zh": {
"orderLabel": "第三十一卦",
"shortName": "咸",
"fullName": "泽山咸",
"keywords": "男女之道",
"summary": "无心之感"
},
"en": {
"orderLabel": "The Thirty-First Hexagram",
"shortName": "Xian",
"fullName": "Lake over Mountain",
"keywords": "Attraction",
"summary": "Feeling without Intent"
}
},
{
"order": 32,
"key": "100-011",
"lines": [
0,
1,
1,
1,
0,
0
],
"zh": {
"orderLabel": "第三十二卦",
"shortName": "恒",
"fullName": "雷风恒",
"keywords": "夫妇之道",
"summary": "恒久恒长"
},
"en": {
"orderLabel": "The Thirty-Second Hexagram",
"shortName": "Heng",
"fullName": "Thunder over Wind",
"keywords": "Constancy",
"summary": "Enduring and Lasting"
}
},
{
"order": 33,
"key": "111-001",
"lines": [
0,
0,
1,
1,
1,
1
],
"zh": {
"orderLabel": "第三十三卦",
"shortName": "遁",
"fullName": "天山遁",
"keywords": "退避三舍",
"summary": "隐遁世外"
},
"en": {
"orderLabel": "The Thirty-Third Hexagram",
"shortName": "Dun",
"fullName": "Heaven over Mountain",
"keywords": "Retreat",
"summary": "Withdrawing from the World"
}
},
{
"order": 34,
"key": "100-111",
"lines": [
1,
1,
1,
1,
0,
0
],
"zh": {
"orderLabel": "第三十四卦",
"shortName": "大壮",
"fullName": "雷天大壮",
"keywords": "阳盛阴衰",
"summary": "壮大隆盛"
},
"en": {
"orderLabel": "The Thirty-Fourth Hexagram",
"shortName": "Da Zhuang",
"fullName": "Thunder over Heaven",
"keywords": "Great Power",
"summary": "Strength and Vigor"
}
},
{
"order": 35,
"key": "101-000",
"lines": [
0,
0,
0,
1,
0,
1
],
"zh": {
"orderLabel": "第三十五卦",
"shortName": "晋",
"fullName": "火地晋",
"keywords": "前进晋升",
"summary": "飞黄腾达"
},
"en": {
"orderLabel": "The Thirty-Fifth Hexagram",
"shortName": "Jin",
"fullName": "Fire over Earth",
"keywords": "Progress",
"summary": "Rising to Glory"
}
},
{
"order": 36,
"key": "000-101",
"lines": [
1,
0,
1,
0,
0,
0
],
"zh": {
"orderLabel": "第三十六卦",
"shortName": "明夷",
"fullName": "地火明夷",
"keywords": "光明负伤",
"summary": "韬光养晦"
},
"en": {
"orderLabel": "The Thirty-Sixth Hexagram",
"shortName": "Ming Yi",
"fullName": "Earth over Fire",
"keywords": "Darkened Light",
"summary": "Hiding One's Brilliance"
}
},
{
"order": 37,
"key": "011-101",
"lines": [
1,
0,
1,
0,
1,
1
],
"zh": {
"orderLabel": "第三十七卦",
"shortName": "家人",
"fullName": "风火家人",
"keywords": "家庭伦理",
"summary": "道德之本"
},
"en": {
"orderLabel": "The Thirty-Seventh Hexagram",
"shortName": "Jia Ren",
"fullName": "Wind over Fire",
"keywords": "The Family",
"summary": "The Root of Virtue"
}
},
{
"order": 38,
"key": "101-110",
"lines": [
1,
1,
0,
1,
0,
1
],
"zh": {
"orderLabel": "第三十八卦",
"shortName": "睽",
"fullName": "火泽睽",
"keywords": "离合之道",
"summary": "同异之变"
},
"en": {
"orderLabel": "The Thirty-Eighth Hexagram",
"shortName": "Kui",
"fullName": "Fire over Lake",
"keywords": "Opposition",
"summary": "Unity in Diversity"
}
},
{
"order": 39,
"key": "010-001",
"lines": [
0,
0,
1,
0,
1,
0
],
"zh": {
"orderLabel": "第三十九卦",
"shortName": "蹇",
"fullName": "水山蹇",
"keywords": "跛脚而行",
"summary": "困难重重"
},
"en": {
"orderLabel": "The Thirty-Ninth Hexagram",
"shortName": "Jian",
"fullName": "Water over Mountain",
"keywords": "Limping",
"summary": "Obstacles Everywhere"
}
},
{
"order": 40,
"key": "100-010",
"lines": [
0,
1,
0,
1,
0,
0
],
"zh": {
"orderLabel": "第四十卦",
"shortName": "解",
"fullName": "雷水解",
"keywords": "化解和解",
"summary": "解除困难"
},
"en": {
"orderLabel": "The Fortieth Hexagram",
"shortName": "Xie",
"fullName": "Thunder over Water",
"keywords": "Deliverance",
"summary": "Resolving Difficulties"
}
},
{
"order": 41,
"key": "001-110",
"lines": [
1,
1,
0,
0,
0,
1
],
"zh": {
"orderLabel": "第四十一卦",
"shortName": "损",
"fullName": "山泽损",
"keywords": "减损之道",
"summary": "损下益上"
},
"en": {
"orderLabel": "The Forty-First Hexagram",
"shortName": "Sun",
"fullName": "Mountain over Lake",
"keywords": "Decrease",
"summary": "Sacrificing the Lower"
}
},
{
"order": 42,
"key": "011-100",
"lines": [
1,
0,
0,
0,
1,
1
],
"zh": {
"orderLabel": "第四十二卦",
"shortName": "益",
"fullName": "风雷益",
"keywords": "增益之道",
"summary": "损上益下"
},
"en": {
"orderLabel": "The Forty-Second Hexagram",
"shortName": "Yi",
"fullName": "Wind over Thunder",
"keywords": "Increase",
"summary": "Benefiting the Lower"
}
},
{
"order": 43,
"key": "110-111",
"lines": [
1,
1,
1,
1,
1,
0
],
"zh": {
"orderLabel": "第四十三卦",
"shortName": "夬",
"fullName": "泽天夬",
"keywords": "断绝决裂",
"summary": "刚决柔也"
},
"en": {
"orderLabel": "The Forty-Third Hexagram",
"shortName": "Guai",
"fullName": "Lake over Heaven",
"keywords": "Breakthrough",
"summary": "The Firm Cuts the Yielding"
}
},
{
"order": 44,
"key": "111-011",
"lines": [
0,
1,
1,
1,
1,
1
],
"zh": {
"orderLabel": "第四十四卦",
"shortName": "姤",
"fullName": "天风姤",
"keywords": "邂逅相遇",
"summary": "柔遇刚也"
},
"en": {
"orderLabel": "The Forty-Fourth Hexagram",
"shortName": "Gou",
"fullName": "Heaven over Wind",
"keywords": "Encountering",
"summary": "The Yielding Meets the Firm"
}
},
{
"order": 45,
"key": "110-000",
"lines": [
0,
0,
0,
1,
1,
0
],
"zh": {
"orderLabel": "第四十五卦",
"shortName": "萃",
"fullName": "泽地萃",
"keywords": "聚集荟萃",
"summary": "无往不利"
},
"en": {
"orderLabel": "The Forty-Fifth Hexagram",
"shortName": "Cui",
"fullName": "Lake over Earth",
"keywords": "Gathering",
"summary": "Success in All Directions"
}
},
{
"order": 46,
"key": "000-011",
"lines": [
0,
1,
1,
0,
0,
0
],
"zh": {
"orderLabel": "第四十六卦",
"shortName": "升",
"fullName": "地风升",
"keywords": "积极向上",
"summary": "步步高升"
},
"en": {
"orderLabel": "The Forty-Sixth Hexagram",
"shortName": "Sheng",
"fullName": "Earth over Wind",
"keywords": "Pushing Up",
"summary": "Rising Step by Step"
}
},
{
"order": 47,
"key": "110-010",
"lines": [
0,
1,
0,
1,
1,
0
],
"zh": {
"orderLabel": "第四十七卦",
"shortName": "困",
"fullName": "泽水困",
"keywords": "深陷穷困",
"summary": "隐忍为要"
},
"en": {
"orderLabel": "The Forty-Seventh Hexagram",
"shortName": "Kun",
"fullName": "Lake over Water",
"keywords": "Oppression",
"summary": "Endurance is Key"
}
},
{
"order": 48,
"key": "010-011",
"lines": [
0,
1,
1,
0,
1,
0
],
"zh": {
"orderLabel": "第四十八卦",
"shortName": "井",
"fullName": "水风井",
"keywords": "水井养人",
"summary": "养贤用贤"
},
"en": {
"orderLabel": "The Forty-Eighth Hexagram",
"shortName": "Jing",
"fullName": "Water over Wind",
"keywords": "The Well",
"summary": "Nourishing the Worthy"
}
},
{
"order": 49,
"key": "110-101",
"lines": [
1,
0,
1,
1,
1,
0
],
"zh": {
"orderLabel": "第四十九卦",
"shortName": "革",
"fullName": "泽火革",
"keywords": "盛衰之际",
"summary": "改革变革"
},
"en": {
"orderLabel": "The Forty-Ninth Hexagram",
"shortName": "Ge",
"fullName": "Lake over Fire",
"keywords": "Revolution",
"summary": "Reform and Change"
}
},
{
"order": 50,
"key": "101-011",
"lines": [
0,
1,
1,
1,
0,
1
],
"zh": {
"orderLabel": "第五十卦",
"shortName": "鼎",
"fullName": "火风鼎",
"keywords": "养贤用贤",
"summary": "除旧布新"
},
"en": {
"orderLabel": "The Fiftieth Hexagram",
"shortName": "Ding",
"fullName": "Fire over Wind",
"keywords": "The Cauldron",
"summary": "Renewal and Stability"
}
},
{
"order": 51,
"key": "100-100",
"lines": [
1,
0,
0,
1,
0,
0
],
"zh": {
"orderLabel": "第五十一卦",
"shortName": "震",
"fullName": "震为雷",
"keywords": "震动戒惧",
"summary": "时刻反省"
},
"en": {
"orderLabel": "The Fifty-First Hexagram",
"shortName": "Zhen",
"fullName": "Thunder",
"keywords": "Shock",
"summary": "Vigilance and Reflection"
}
},
{
"order": 52,
"key": "001-001",
"lines": [
0,
0,
1,
0,
0,
1
],
"zh": {
"orderLabel": "第五十二卦",
"shortName": "艮",
"fullName": "艮为山",
"keywords": "阻止停止",
"summary": "止所当止"
},
"en": {
"orderLabel": "The Fifty-Second Hexagram",
"shortName": "Gen",
"fullName": "Mountain",
"keywords": "Keeping Still",
"summary": "Stopping at the Right Time"
}
},
{
"order": 53,
"key": "011-001",
"lines": [
0,
0,
1,
0,
1,
1
],
"zh": {
"orderLabel": "第五十三卦",
"shortName": "渐",
"fullName": "风山渐",
"keywords": "循序渐进",
"summary": "遵循节律"
},
"en": {
"orderLabel": "The Fifty-Third Hexagram",
"shortName": "Jian",
"fullName": "Wind over Mountain",
"keywords": "Gradual Progress",
"summary": "Following the Rhythm"
}
},
{
"order": 54,
"key": "100-110",
"lines": [
1,
1,
0,
1,
0,
0
],
"zh": {
"orderLabel": "第五十四卦",
"shortName": "归妹",
"fullName": "雷泽归妹",
"keywords": "少女出嫁",
"summary": "归宿各异"
},
"en": {
"orderLabel": "The Fifty-Fourth Hexagram",
"shortName": "Gui Mei",
"fullName": "Thunder over Lake",
"keywords": "The Marriageable Maiden",
"summary": "Different Destinies"
}
},
{
"order": 55,
"key": "100-101",
"lines": [
1,
0,
1,
1,
0,
0
],
"zh": {
"orderLabel": "第五十五卦",
"shortName": "丰",
"fullName": "雷火丰",
"keywords": "盛极防衰",
"summary": "守成不易"
},
"en": {
"orderLabel": "The Fifty-Fifth Hexagram",
"shortName": "Feng",
"fullName": "Thunder over Fire",
"keywords": "Abundance",
"summary": "Guarding Success"
}
},
{
"order": 56,
"key": "101-001",
"lines": [
0,
0,
1,
1,
0,
1
],
"zh": {
"orderLabel": "第五十六卦",
"shortName": "旅",
"fullName": "火山旅",
"keywords": "居无定所",
"summary": "颠沛流离"
},
"en": {
"orderLabel": "The Fifty-Sixth Hexagram",
"shortName": "Lu",
"fullName": "Fire over Mountain",
"keywords": "The Wanderer",
"summary": "Drifting and Wandering"
}
},
{
"order": 57,
"key": "011-011",
"lines": [
0,
1,
1,
0,
1,
1
],
"zh": {
"orderLabel": "第五十七卦",
"shortName": "巽",
"fullName": "巽为风",
"keywords": "申命行事",
"summary": "君命难违"
},
"en": {
"orderLabel": "The Fifty-Seventh Hexagram",
"shortName": "Xun",
"fullName": "Wind",
"keywords": "The Gentle",
"summary": "Following the Decree"
}
},
{
"order": 58,
"key": "110-110",
"lines": [
1,
1,
0,
1,
1,
0
],
"zh": {
"orderLabel": "第五十八卦",
"shortName": "兑",
"fullName": "兑为泽",
"keywords": "和悦喜悦",
"summary": "取悦之道"
},
"en": {
"orderLabel": "The Fifty-Eighth Hexagram",
"shortName": "Dui",
"fullName": "Lake",
"keywords": "Joy",
"summary": "The Way of Pleasing"
}
},
{
"order": 59,
"key": "011-010",
"lines": [
0,
1,
0,
0,
1,
1
],
"zh": {
"orderLabel": "第五十九卦",
"shortName": "涣",
"fullName": "风水涣",
"keywords": "涣之所用",
"summary": "利涉大川"
},
"en": {
"orderLabel": "The Fifty-Ninth Hexagram",
"shortName": "Huan",
"fullName": "Wind over Water",
"keywords": "Dispersion",
"summary": "Crossing the Great River"
}
},
{
"order": 60,
"key": "010-110",
"lines": [
1,
1,
0,
0,
1,
0
],
"zh": {
"orderLabel": "第六十卦",
"shortName": "节",
"fullName": "水泽节",
"keywords": "约束欲望",
"summary": "适度节制"
},
"en": {
"orderLabel": "The Sixtieth Hexagram",
"shortName": "Jie",
"fullName": "Water over Lake",
"keywords": "Moderation",
"summary": "Restraint and Limits"
}
},
{
"order": 61,
"key": "011-110",
"lines": [
1,
1,
0,
0,
1,
1
],
"zh": {
"orderLabel": "第六十一卦",
"shortName": "中孚",
"fullName": "风泽中孚",
"keywords": "修德立命",
"summary": "诚信为本"
},
"en": {
"orderLabel": "The Sixty-First Hexagram",
"shortName": "Zhong Fu",
"fullName": "Wind over Lake",
"keywords": "Inner Truth",
"summary": "Sincerity is Fundamental"
}
},
{
"order": 62,
"key": "100-001",
"lines": [
0,
0,
1,
1,
0,
0
],
"zh": {
"orderLabel": "第六十二卦",
"shortName": "小过",
"fullName": "雷山小过",
"keywords": "凡事勿过",
"summary": "过犹不及"
},
"en": {
"orderLabel": "The Sixty-Second Hexagram",
"shortName": "Xiao Guo",
"fullName": "Thunder over Mountain",
"keywords": "Small Excess",
"summary": "Too Much is Too Little"
}
},
{
"order": 63,
"key": "010-101",
"lines": [
1,
0,
1,
0,
1,
0
],
"zh": {
"orderLabel": "第六十三卦",
"shortName": "既济",
"fullName": "水火既济",
"keywords": "一切大成",
"summary": "初吉终乱"
},
"en": {
"orderLabel": "The Sixty-Third Hexagram",
"shortName": "Ji Ji",
"fullName": "Water over Fire",
"keywords": "Completion",
"summary": "Order turns to Chaos"
}
},
{
"order": 64,
"key": "101-010",
"lines": [
0,
1,
0,
1,
0,
1
],
"zh": {
"orderLabel": "第六十四卦",
"shortName": "未济",
"fullName": "火水未济",
"keywords": "终则必始",
"summary": "变化无穷"
},
"en": {
"orderLabel": "The Sixty-Fourth Hexagram",
"shortName": "Wei Ji",
"fullName": "Fire over Water",
"keywords": "Not Yet Completed",
"summary": "Infinite Change"
}
}
]
FILE:references/summary.txt
乾 ☰ 乾上 第一卦 至刚至强
乾 ☰ 乾下 乾为天 为君之道
坤 ☷ 坤上 第二卦 至柔至顺
坤 ☷ 坤下 坤为地 为臣之道
屯 ☵ 坎上 第三卦 阴阳交动
屯 ☳ 震下 水雷屯 万物始生
蒙 ☶ 艮上 第四卦 启蒙教育
蒙 ☵ 坎下 山水蒙 童蒙求我
需 ☵ 坎上 第五卦 凶险在前
需 ☰ 乾下 水天需 君子等待
讼 ☰ 乾上 第六卦 争诉争讼
讼 ☵ 坎下 天水讼 中吉终凶
师 ☷ 坤上 第七卦 军队战争
师 ☵ 坎下 地水师 以正为要
比 ☵ 坎上 第八卦 择善依附
比 ☷ 坤下 水地比 迟则凶险
小 ☴ 巽上 第九卦 小阻小畜
畜 ☰ 乾下 风天小畜 阴止阳也
履 ☰ 乾上 第十卦 践行履职
履 ☱ 兑下 天泽履 如履薄冰
泰 ☷ 坤上 第十一卦 万物亨通
泰 ☰ 乾下 地天泰 安泰吉祥
否 ☰ 乾上 第十二卦 天地不交
否 ☷ 坤下 天地否 闭塞黑暗
同 ☰ 乾上 第十三卦 天下大同
人 ☲ 离下 天火同人 利涉大川
大 ☲ 离上 第十四卦 阳光普照
有 ☰ 乾下 火天大有 天下富有
谦 ☷ 坤上 第十五卦 谦虚美德
谦 ☶ 艮下 地山谦 君子有终
豫 ☳ 震上 第十六卦 顺时而动
豫 ☷ 坤下 雷地豫 喜悦安乐
随 ☱ 兑上 第十七卦 追随伟人
随 ☳ 震下 泽雷随 随和众人
蛊 ☶ 艮上 第十八卦 革除腐败
蛊 ☴ 巽下 山风蛊 除旧布新
临 ☷ 坤上 第十九卦 居高临下
临 ☱ 兑下 地泽临 监督教化
观 ☴ 巽上 第二十卦 展示威严
观 ☷ 坤下 风地观 仰观盛德
噬 ☲ 离上 第二十一卦 刑罚咬合
嗑 ☳ 震下 火雷噬嗑 用狱规则
贲 ☶ 艮上 第二十二卦 装饰外表
贲 ☲ 离下 山火贲 美化形象
剥 ☶ 艮上 第二十三卦 蚕食剥落
剥 ☷ 坤下 山地剥 君子道消
复 ☷ 坤上 第二十四卦 回复返复
复 ☳ 震下 地雷复 复归复来
无 ☰ 乾上 第二十五卦 毫不虚伪
妄 ☳ 震下 天雷无妄 本该如此
大 ☶ 艮上 第二十六卦 大止大畜
畜 ☰ 乾下 山天大畜 大有作为
颐 ☶ 艮上 第二十七卦 养人被养
颐 ☳ 震下 山雷颐 正当则吉
大 ☱ 兑上 第二十八卦 大的过度
过 ☴ 巽下 泽风大过 非常行动
坎 ☵ 坎上 第二十九卦 处处陷阱
坎 ☵ 坎下 水为坎 重重艰险
离 ☲ 离上 第三十卦 附着依附
离 ☲ 离下 火为离 光明文明
咸 ☱ 兑上 第三十一卦 男女之道
咸 ☶ 艮下 泽山咸 无心之感
恒 ☳ 震上 第三十二卦 夫妇之道
恒 ☴ 巽下 雷风恒 恒久恒长
遁 ☰ 乾上 第三十三卦 退避三舍
遁 ☶ 艮下 天山遁 隐遁世外
大 ☳ 震上 第三十四卦 阳盛阴衰
壮 ☰ 乾下 雷天大壮 壮大隆盛
晋 ☲ 离上 第三十五卦 前进晋升
晋 ☷ 坤下 火地晋 飞黄腾达
明 ☷ 坤上 第三十六卦 光明负伤
夷 ☲ 离下 地火明夷 韬光养晦
家 ☴ 巽上 第三十七卦 家庭伦理
人 ☲ 离下 风火家人 道德之本
睽 ☲ 离上 第三十八卦 离合之道
睽 ☱ 兑下 火泽睽 同异之变
蹇 ☵ 坎上 第三十九卦 跛脚而行
蹇 ☶ 艮下 水山蹇 困难重重
解 ☳ 震上 第四十卦 化解和解
解 ☵ 坎下 雷水解 解除困难
损 ☶ 艮上 第四十一卦 减损之道
损 ☱ 兑下 山泽损 损下益上
益 ☴ 巽上 第四十二卦 增益之道
益 ☳ 震下 风雷益 损上益下
夬 ☱ 兑上 第四十三卦 断绝决裂
夬 ☰ 乾下 泽天夬 刚决柔也
姤 ☰ 乾上 第四十四卦 邂逅相遇
姤 ☴ 巽下 天风姤 柔遇刚也
萃 ☱ 兑上 第四十五卦 聚集荟萃
萃 ☷ 坤下 泽地萃 无往不利
升 ☷ 坤上 第四十六卦 积极向上
升 ☴ 巽下 地风升 步步高升
困 ☱ 兑上 第四十七卦 深陷穷困
困 ☵ 坎下 泽水困 隐忍为要
井 ☵ 坎上 第四十八卦 水井养人
井 ☴ 巽下 水风井 养贤用贤
革 ☱ 兑上 第四十九卦 盛衰之际
革 ☲ 离下 泽火革 改革变革
鼎 ☲ 离上 第五十卦 养贤用贤
鼎 ☴ 巽下 火风鼎 除旧布新
震 ☳ 震上 第五十一卦 震动戒惧
震 ☳ 震下 震为雷 时刻反省
艮 ☶ 艮上 第五十二卦 阻止停止
艮 ☶ 艮下 艮为山 止所当止
渐 ☴ 巽上 第五十三卦 循序渐进
渐 ☶ 艮下 风山渐 遵循节律
归 ☳ 震上 第五十四卦 少女出嫁
妹 ☱ 兑下 雷泽归妹 归宿各异
丰 ☳ 震上 第五十五卦 盛极防衰
丰 ☲ 离下 雷火丰 守成不易
旅 ☲ 离上 第五十六卦 居无定所
旅 ☶ 艮下 火山旅 颠沛流离
巽 ☴ 巽上 第五十七卦 申命行事
巽 ☴ 巽下 巽为风 君命难违
兑 ☱ 兑上 第五十八卦 和悦喜悦
兑 ☱ 兑下 兑为泽 取悦之道
涣 ☴ 巽上 第五十九卦 涣之所用
涣 ☵ 坎下 风水涣 利涉大川
节 ☵ 坎上 第六十卦 约束欲望
节 ☱ 兑下 水泽节 适度节制
中 ☴ 巽上 第六十一卦 修德立命
孚 ☱ 兑下 风泽中孚 诚信为本
小 ☳ 震上 第六十二卦 凡事勿过
过 ☶ 艮下 雷山小过 过犹不及
既 ☵ 坎上 第六十三卦 一切大成
济 ☲ 离下 水火既济 初吉终乱
未 ☲ 离上 第六十四卦 终则必始
济 ☵ 坎下 火水未济 变化无穷
World timezone converter — convert times across 200+ cities worldwide. Perfect for international calls, remote work, travel planning, and global business. Fe...
---
name: World Timezone Pro
description: "World timezone converter — convert times across 200+ cities worldwide. Perfect for international calls, remote work, travel planning, and global business. Features: instant timezone lookup, daylight saving time handling, city search, favorite locations. 支持北京、上海、纽约、伦敦、东京等主要城市时区转换。"
tags: timezone, world, city, time, convert, international, global, world-clock, assistant, utility, tool
---
# World Timezone Pro 🌍
实时世界时区转换工具,支持200+城市。
## Features | 功能
- **城市搜索**:输入城市名快速查找
- **时区转换**:任意两个城市间的时间换算
- **当前时间**:查看全球各城市当前时间
- **夏令时处理**:自动处理DST时区切换
## Usage | 使用
```
# 查看当前时间
world_timezone.py now
# 转换时间
world_timezone.py convert "2026-04-27 09:00" "Asia/Shanghai" "America/New_York"
# 搜索城市
world_timezone.py search "Shanghai"
```
---
*免责声明:本工具仅供学习参考,不构成任何投资或商业建议。*
FILE:scripts/timezone.py
#!/usr/bin/env python3
"""
World Timezone Pro - 多时区工作时钟
Author: Lin Hui
"""
import sys
import json
import subprocess
from datetime import datetime, timezone, timedelta
# 60+ 常用城市时区
TIMEZONE_MAP = {
# 中国
"beijing": "Asia/Shanghai",
"shanghai": "Asia/Shanghai",
"china": "Asia/Shanghai",
"cst": "Asia/Shanghai",
"hongkong": "Asia/Hong_Kong",
"hk": "Asia/Hong_Kong",
"taipei": "Asia/Taipei",
"taiwan": "Asia/Taipei",
# 北美
"newyork": "America/New_York",
"nyc": "America/New_York",
"losangeles": "America/Los_Angeles",
"la": "America/Los_Angeles",
"sanfrancisco": "America/Los_Angeles",
"sf": "America/Los_Angeles",
"chicago": "America/Chicago",
"toronto": "America/Toronto",
"vancouver": "America/Vancouver",
"seattle": "America/Los_Angeles",
"boston": "America/New_York",
"dc": "America/New_York",
"washington": "America/New_York",
"denver": "America/Denver",
"phoenix": "America/Phoenix",
"miami": "America/New_York",
"atlanta": "America/New_York",
"mexico": "America/Mexico_City",
# 欧洲
"london": "Europe/London",
"uk": "Europe/London",
"paris": "Europe/Paris",
"france": "Europe/Paris",
"berlin": "Europe/Berlin",
"germany": "Europe/Berlin",
"amsterdam": "Europe/Amsterdam",
"zurich": "Europe/Zurich",
"milan": "Europe/Rome",
"rome": "Europe/Rome",
"madrid": "Europe/Madrid",
"barcelona": "Europe/Madrid",
"lisbon": "Europe/Lisbon",
"dublin": "Europe/Dublin",
"moscow": "Europe/Moscow",
"russia": "Europe/Moscow",
"stockholm": "Europe/Stockholm",
"oslo": "Europe/Oslo",
"vienna": "Europe/Vienna",
"prague": "Europe/Prague",
"warsaw": "Europe/Warsaw",
"athens": "Europe/Athens",
"helsinki": "Europe/Helsinki",
"zurich": "Europe/Zurich",
# 亚太
"tokyo": "Asia/Tokyo",
"japan": "Asia/Tokyo",
"osaka": "Asia/Tokyo",
"seoul": "Asia/Seoul",
"korea": "Asia/Seoul",
"singapore": "Asia/Singapore",
"sg": "Asia/Singapore",
"mumbai": "Asia/Kolkata",
"delhi": "Asia/Kolkata",
"india": "Asia/Kolkata",
"bangalore": "Asia/Kolkata",
"shanghai_time": "Asia/Shanghai",
"sydney": "Australia/Sydney",
"melbourne": "Australia/Melbourne",
"australia": "Australia/Sydney",
"auckland": "Pacific/Auckland",
"jakarta": "Asia/Jakarta",
"bangkok": "Asia/Bangkok",
"manila": "Asia/Manila",
"kuala": "Asia/Kuala_Lumpur",
"kualalumpur": "Asia/Kuala_Lumpur",
"dubai": "Asia/Dubai",
"uae": "Asia/Dubai",
"telaviv": "Asia/Jerusalem",
"tel-aviv": "Asia/Jerusalem",
# 南美/非洲
"saopaulo": "America/Sao_Paulo",
"sao-paulo": "America/Sao_Paulo",
"brazil": "America/Sao_Paulo",
"buenosaires": "America/Argentina/Buenos_Aires",
"argentina": "America/Argentina/Buenos_Aires",
"lagos": "Africa/Lagos",
"nairobi": "Africa/Nairobi",
"cairo": "Africa/Cairo",
"egypt": "Africa/Cairo",
"johannesburg": "Africa/Johannesburg",
"southafrica": "Africa/Johannesburg",
"dubai": "Asia/Dubai",
# 其他
"utc": "UTC",
"gmt": "UTC",
}
# 城市中文名映射
CITY_NAMES_CN = {
"beijing": "北京", "shanghai": "上海", "china": "中国",
"hongkong": "香港", "taipei": "台北",
"newyork": "纽约", "losangeles": "洛杉矶", "la": "洛杉矶",
"chicago": "芝加哥", "toronto": "多伦多",
"london": "伦敦", "paris": "巴黎", "berlin": "柏林",
"tokyo": "东京", "seoul": "首尔",
"singapore": "新加坡", "sydney": "悉尼",
"dubai": "迪拜", "moscow": "莫斯科",
"saopaulo": "圣保罗", "mumbai": "孟买",
}
# 商务时间(9:00-18:00)
WORK_START = 9
WORK_END = 18
def get_time_in_tz(tz_name: str) -> dict:
"""Get current time in a given timezone."""
try:
result = subprocess.run(
["date", "-u", "+%Y-%m-%d %H:%M:%S %z %Z"],
env={"TZ": tz_name},
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
line = result.stdout.strip()
parts = line.split()
dt_str = " ".join(parts[:2])
tz_abbr = parts[2] if len(parts) > 2 else ""
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
return {
"timezone": tz_name,
"datetime": dt_str,
"abbr": tz_abbr,
"hour": dt.hour,
"minute": dt.minute,
}
except Exception:
pass
return None
def get_time_in_city(city: str) -> dict:
"""Get time in a city by name."""
city_lower = city.lower().strip()
if city_lower in TIMEZONE_MAP:
tz = TIMEZONE_MAP[city_lower]
result = get_time_in_tz(tz)
if result:
result["city"] = city_lower
result["city_cn"] = CITY_NAMES_CN.get(city_lower, city_lower)
return result
return {"city": city, "error": "City not found"}
def cmd_now(cities: list) -> None:
"""Show current time for multiple cities."""
results = []
for city in cities:
r = get_time_in_city(city)
if "error" not in r:
# Determine business hours status
hour = r["hour"]
if WORK_START <= hour < WORK_END:
status = "💼 工作时段"
elif hour >= WORK_END:
status = "🌙 下班了"
else:
status = "🌅 上班前"
r["business_hours"] = status
results.append(r)
print(json.dumps({"cities": results}, ensure_ascii=False, indent=2))
def cmd_meeting(cities: list) -> None:
"""Find the best meeting time across multiple timezones."""
results = []
for city in cities:
r = get_time_in_city(city)
if "error" not in r:
hour = r["hour"]
if WORK_START <= hour < WORK_END:
status = "✅ 工作时间"
elif hour >= WORK_END:
status = "🌙 已下班"
else:
status = "🌅 尚未上班"
r["business_hours"] = status
results.append(r)
print(json.dumps({"meeting_check": results}, ensure_ascii=False, indent=2))
def cmd_convert(args: list) -> None:
"""Convert a time from one timezone to another."""
if len(args) < 3:
print(json.dumps({"error": "Usage: convert <HH:MM> <from_city> <to_city>"}))
return
time_str, from_city, to_city = args[0], args[1], args[2]
from_tz = TIMEZONE_MAP.get(from_city.lower())
to_tz = TIMEZONE_MAP.get(to_city.lower())
if not from_tz or not to_tz:
print(json.dumps({"error": "City not found in timezone map"}))
return
try:
result = subprocess.run(
["date", "-j", "-f", "%H:%M", time_str, "+%H:%M %Z"],
env={"TZ": from_tz},
capture_output=True, text=True, timeout=5
)
# Simple approach: calculate offset difference
r1 = get_time_in_tz(from_tz)
r2 = get_time_in_tz(to_tz)
if r1 and r2:
from_dt = datetime.strptime(r1["datetime"].split()[1], "%H:%M:%S")
to_dt = datetime.strptime(r2["datetime"].split()[1], "%H:%M:%S")
# Show current offset
print(json.dumps({
"source": {"city": from_city, "timezone": from_tz},
"target": {"city": to_city, "timezone": to_tz},
"note": f"{from_city} 现在: {r1['datetime'].split()[1][:5]}, {to_city} 现在: {r2['datetime'].split()[1][:5]}"
}, ensure_ascii=False, indent=2))
except Exception as e:
print(json.dumps({"error": str(e)}))
def cmd_all() -> None:
"""Show all major cities at once."""
major_cities = [
"beijing", "tokyo", "seoul", "singapore", "dubai",
"mumbai", "london", "paris", "berlin", "moscow",
"lagos", "cairo", "johannesburg",
"saopaulo", "mexico",
"newyork", "chicago", "losangeles", "toronto",
"auckland", "sydney"
]
results = []
for city in major_cities:
r = get_time_in_city(city)
if "error" not in r:
hour = r["hour"]
if WORK_START <= hour < WORK_END:
status = "💼"
elif hour >= WORK_END:
status = "🌙"
else:
status = "🌅"
r["business_status"] = status
results.append(r)
print(json.dumps({"world_clock": results}, ensure_ascii=False, indent=2))
def main():
if len(sys.argv) < 2:
print("Usage: timezone.py <command> [args...]")
print("Commands: now, meeting, convert, all")
sys.exit(1)
cmd = sys.argv[1]
args = sys.argv[2:]
if cmd == "now":
cmd_now(args if args else ["beijing", "london", "newyork"])
elif cmd == "meeting":
cmd_meeting(args if args else ["beijing", "london", "newyork"])
elif cmd == "convert":
cmd_convert(args)
elif cmd == "all":
cmd_all()
else:
print(f"Unknown command: {cmd}")
if __name__ == "__main__":
main()
Guide systematic customer acquisition channel selection using the Bullseye Framework. Use whenever a startup founder, growth marketer, or product leader is d...
---
name: bullseye-channel-selection
description: "Guide systematic customer acquisition channel selection using the Bullseye Framework. Use whenever a startup founder, growth marketer, or product leader is deciding which marketing channel to focus on, evaluating customer acquisition options, choosing between viral, SEO, SEM, content, sales, PR, or any other growth channel, struggling with where to invest marketing budget, trying to escape channel bias, asking 'how do we get customers', planning a go-to-market, or needs to narrow 19 possible channels down to one focused bet. Activates on phrases like 'channel selection', 'customer acquisition', 'marketing strategy', 'growth channel', 'traction channel', 'Bullseye Framework', 'which channel should we use', 'how do we grow', 'marketing plan', or any discussion of prioritizing acquisition investments."
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/traction/skills/bullseye-channel-selection
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: draft
source-books:
- id: traction
title: "Traction: A Startup Guide to Getting Customers"
authors: ["Gabriel Weinberg", "Justin Mares"]
chapters: [2, 3]
domain: startup-growth
tags: [startup-growth, customer-acquisition, channel-selection, marketing-strategy, growth-marketing]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Startup context — product description, stage, target customer, traction goal, budget"
tools-required: [Read, Write]
tools-optional: [AskUserQuestion]
mcps-required: []
environment: "Plain-text working directory for channel evaluation documents and ranked shortlist"
discovery:
goal: "Select the single customer acquisition channel most likely to produce traction at the current startup stage"
tasks:
- "Generate ideas for all 19 traction channels to counteract founder bias"
- "Rank channels into Inner Circle, Potential, and Long-shot tiers"
- "Identify exactly 3 inner-circle channels to test in parallel"
- "Design cheap tests for each inner-circle channel"
- "Focus resources on the single channel producing best test results"
audience:
roles: [startup-founder, growth-marketer, head-of-marketing]
experience: beginner-to-intermediate
when_to_use:
triggers:
- "User asks which marketing channel to pursue"
- "User is stuck in one channel and needs to explore alternatives"
- "User has a new product and no traction strategy yet"
- "User's current channel is saturating (rising CAC, falling CTR)"
prerequisites: []
not_for:
- "User has already validated a single working channel and wants to optimize it (use A/B testing skill)"
- "User is pre-product — no product exists to acquire customers for yet"
environment:
codebase_required: false
codebase_helpful: false
works_offline: true
quality:
scores:
with_skill: null
baseline: null
delta: null
tested_at: null
eval_count: 0
assertion_count: 12
iterations_needed: 0
---
# Bullseye Channel Selection
## When to Use
You need to choose a customer acquisition channel for a startup, and the answer is not obvious. Before starting, verify:
- The product exists in some usable form (pre-product → product work comes first)
- The user can describe their target customer (even roughly)
- The user is open to considering channels they haven't tried before
If the user is already invested in a channel that's producing results, they likely want to *optimize* that channel, not re-select. Ask before running this skill.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Product description:** what it is, who it's for, what stage it's at
→ Check prompt for: product name, category, target user description
→ If missing, ask: "What does your product do, and who is the target customer?"
- **Traction goal:** a specific numeric target (users, revenue, signups) over a specific timeframe
→ Check prompt for: numbers with "users", "customers", "revenue", timeframes
→ If missing, ask: "What's your traction goal? For example: '1,000 paying customers in 6 months' or '10,000 signups by end of quarter'."
- **Budget envelope:** rough dollar range available for channel testing
→ Check prompt for: dollar amounts, "budget", "can spend"
→ If missing, ask: "Roughly what budget do you have for testing acquisition channels? Even $500-$2,000 is enough to start."
### Observable Context (gather from environment)
- **Startup phase:** Phase I (pre-product-market-fit), Phase II (fit established, scaling traction), Phase III (scaling business)
→ Infer from: user count, revenue, team size, product maturity
→ Default: assume Phase I if unclear
- **Current channels tried:** what the user has already attempted
→ Infer from: references to "we tried", "didn't work", "used to"
### Default Assumptions
- Tests should cost $250-$500 each. A four-ad test is enough — forty is over-engineering.
- Inner circle = exactly 3 channels, tested in parallel.
- All 19 channels must be brainstormed, even ones the user dismisses.
### Sufficiency Threshold
```
SUFFICIENT: product description + traction goal + budget known
PROCEED WITH DEFAULTS: product and goal known, budget assumed at $1,500
MUST ASK: product description is missing
```
## Process
Use TodoWrite to track the 5 Bullseye steps:
- [ ] Step 1: Brainstorm (1 idea per all 19 channels)
- [ ] Step 2: Rank into Columns A/B/C
- [ ] Step 3: Prioritize — exactly 3 inner-circle channels
- [ ] Step 4: Test (design cheap parallel tests)
- [ ] Step 5: Focus (direct resources to the winner)
For each step, mark `in_progress` when starting and `completed` when done.
### Step 1: Brainstorm (All 19 Channels)
**ACTION:** Generate at least one concrete channel idea for every one of the 19 channels listed in [references/traction-channels.md](references/traction-channels.md). Write the ideas into a brainstorm table with these columns:
| Channel | Idea | Probability (1-5) | Est. CAC | Est. Volume | Test Timeframe |
Before scoring probability, explicitly note any channels the user dismissed. Ask why. The answer usually reveals one of three biases — see [references/channel-biases.md](references/channel-biases.md).
**WHY:** Founders have blind spots. They reach for channels they know (engineers → Engineering as Marketing; salespeople → Sales) and ignore whole categories. Peter Thiel: "Most businesses actually get zero distribution channels to work. Poor distribution — not product — is the number one cause of failure." Brainstorming every channel, even ones the user considers "not for us", is the systematic counter to this bias. Skipping channels here means skipping the channel that could actually work.
**IF** the user dismisses a channel without evidence → flag the bias type (invisible / negative / schlep) and still generate one idea for it.
**IF** a channel genuinely has no plausible idea → note "no viable idea" with 1-sentence reasoning. Do not skip the row.
### Step 2: Rank Into Columns A / B / C
**ACTION:** Sort each of the 19 channel ideas into three columns:
- **Column A (Inner Circle):** channels that seem most promising right now given the product, audience, and stage
- **Column B (Potential):** channels that could plausibly work but feel less certain
- **Column C (Long-shot):** channels where only stretch ideas exist
Output the ranked three-column table.
**WHY:** Ranking forces explicit prioritization. Without this step, founders treat all channels as equally viable and end up testing whichever is most convenient. The three-column structure creates a visible bar: a channel is in A only if it beats the alternatives on probability, CAC, and volume — not because it's familiar.
**IF** Column A has more than 3 channels → proceed to Step 3 to cut.
**IF** Column A has fewer than 3 channels → promote the strongest Column B entries until you have 3.
### Step 3: Prioritize — Inner Circle Exactly 3
**ACTION:** From Column A, identify exactly 3 channels for the inner circle. If Column A has more than 3, look for the natural drop-off in excitement between candidates — usually around position 3. Eliminate below the drop-off. If fewer than 3, promote from Column B.
Write the inner circle to `channel-inner-circle.md` with one paragraph per channel explaining why it qualified.
**WHY:** Three is a deliberate number. Testing 1 channel sequentially wastes time — you learn nothing about alternatives. Testing 5+ channels in parallel fractures focus and produces noisy results ("kitchen sink distribution" — Thiel's named failure mode). Three channels tested in parallel takes the same clock time as one and produces comparative data. The correct channel is unpredictable before testing, so parallel is how you discover it.
**IF** the user insists on more than 3 → explain the focus cost. If they still want more, note the deviation in the output and proceed with 3 for the formal Bullseye cycle.
### Step 4: Design Cheap Parallel Tests
**ACTION:** For each of the 3 inner-circle channels, design a cheap test that answers these four questions:
1. Roughly how much will it cost to acquire customers through this channel?
2. How many customers are available through this channel at that cost?
3. Are these the customers you want right now?
4. How long does it take to acquire a customer through this channel?
Target test budget: $250-$500 per channel. Use 4 ads, not 40. Speed to data matters more than test sophistication. Write `channel-test-plan.md` with hypothesis, budget, success metrics, and timeline per channel.
**WHY:** Inner-circle tests are validation experiments, not optimization. Founders confuse these and spend weeks A/B-testing a channel before knowing it works at all. Cheap tests ($250 on AdWords) give enough signal to rule a channel in or out — rule *out* is the primary goal. A/B testing to wring out an extra 15% conversion matters only after you've proven the channel can work at all.
**IF** tracking/reporting is not in place yet → stop and build it first. Sean Ellis: "Don't start testing until your tracking/reporting system has been implemented." A test with no measurement is a waste of budget.
### Step 5: Focus on the Winner
**ACTION:** After tests complete, compare results across the four questions. Direct all channel resources to the single channel with the strongest signal. Write `channel-focus-strategy.md` with the chosen channel, the evidence from testing, and the optimization plan (A/B testing cadence, budget scaling, team allocation).
If no channel showed promise, document what you learned and repeat Steps 1-4. Use the test data to refine the next brainstorm — which assumptions were wrong?
**WHY:** Focus is where traction actually happens. Spreading resources across multiple channels after testing is the kitchen sink failure mode again, just later in the cycle. If Channel A showed a clear signal and Channel B showed a weaker one, doubling down on A produces more traction than hedging across both. Compound returns come from depth, not breadth.
**IF** two channels tied → pick based on strategic fit with the next growth phase, not the current test alone. A channel that works now but doesn't scale (personal outreach in Phase II) is worse than a channel that works now and scales (content marketing).
## Inputs
- Product description and stage
- Traction goal (specific, numeric, time-bound)
- Budget for channel testing
- Current channels tried (if any)
## Outputs
Produces four deliverables in the working directory:
1. **`channel-brainstorm.md`** — 19-row table with ideas, probability scores, CAC/volume estimates, test timeframes
2. **`channel-rankings.md`** — Three-column A/B/C table with all 19 channels sorted
3. **`channel-inner-circle.md`** — The 3 selected channels with qualification reasoning
4. **`channel-test-plan.md`** — Cheap test design per channel (hypothesis, budget, metrics, timeline)
5. **`channel-focus-strategy.md`** *(after tests complete)* — Chosen channel + optimization plan
## Key Principles
- **Don't dismiss any channel in the brainstorm.** The channel you skip because it "obviously won't work" is the one a competitor will use to beat you. WHY: Founder bias is the single biggest failure mode in channel selection. Every channel gets one idea — this is non-negotiable.
- **Three in parallel, not one at a time.** Sequential testing wastes calendar time. Five in parallel fractures focus. Three is the Goldilocks number — enough parallelism to compare, not so much that you lose discipline. WHY: The correct channel is unpredictable before testing, so you can't just "pick the right one first". Parallel comparison is how you discover it.
- **Cheap validation before expensive optimization.** Inner-circle tests rule channels *out*, not in. Spend $250 to learn if a channel has any signal, not $25,000 to optimize a channel you haven't validated. WHY: Premature optimization is the most common testing failure. A/B testing is valuable only after the channel itself is proven.
- **Repeat Bullseye at every growth-stage transition.** A channel that worked in Phase I will often saturate in Phase II. When your current channel's CAC starts climbing or CTR starts falling (Law of Shitty Click-Throughs), run Bullseye again with the data you've accumulated. WHY: Channels have a lifecycle. Treating Bullseye as a one-time decision locks you into a channel past its useful life.
- **Focus after the test, not during.** Once a winner emerges, all resources go to that channel — not hedged across the top two. Compound returns come from depth. WHY: The startup's biggest asset is focused attention. Diluting it across channels is the kitchen sink failure at a different scale.
## Examples
**Scenario: B2B SaaS founder with no traction strategy**
Trigger: "We built a project management tool for construction teams. Launched 3 months ago. Have 40 paying customers from personal outreach. Need to get to 500 in 6 months. Budget: $3,000/month for marketing. What should we do?"
Process: (1) Brainstorm all 19 channels — note the founder dismissed Trade Shows as "not for us" (flagged as schlep bias; construction expos are where this audience lives). (2) Rank: Column A = Sales (SDR outreach), Trade Shows (construction expos), Targeting Blogs (construction-industry blogs). Column B = BD (integration partnerships), Content Marketing, SEM. Column C = Viral, Affiliate, Community. (3) Inner circle: Sales, Trade Shows, Targeting Blogs. (4) Tests: SDR with 100 cold emails ($500), booth sponsorship at one small construction meetup ($800), paid sponsorship on top 2 construction blogs ($700). (5) Two weeks later: sponsored blog posts had clear winner — $40 CAC, 25 signups. Focus: double down on Targeting Blogs, expand to 5 more blogs, build library of 3 guest posts per month.
Output: 4 markdown files in working directory, clear channel winner with evidence, next-4-weeks plan.
**Scenario: Consumer app stuck in Engineering as Marketing tunnel**
Trigger: "We built a free calculator tool that ranks on Google for 'loan calculator'. Drives 50k visits/month but only 200 signups. Engineering team keeps building more calculators. Growth has plateaued. What now?"
Process: (1) Brainstorm forces the founder to consider channels beyond Engineering as Marketing. Notes: "Viral Marketing — we haven't even thought about this; our calculators could include share hooks." (2) Rank: Column A = Viral Marketing (embed calculators as widgets on finance blogs), Content Marketing (loan advice articles with calculator CTAs), Email Marketing (nurture the 200 signups). Column B = PR, SEM, Targeting Blogs. Column C = Sales, Trade Shows, Offline Events. (3) Inner circle: Viral (widgets), Content, Email. (4) Tests: 3 widgets on blogs ($0 — engineering time), 5 long-form articles ($1,500 freelance), email drip sequence (existing 200 contacts). (5) Content articles converted 4x better than widgets — focus on content, commission 2 articles/week.
Output: Founder breaks out of "just build more calculators" loop. Discovers Content Marketing is the real channel; Engineering as Marketing was actually serving SEO, not acquisition.
**Scenario: Repeating Bullseye after saturation**
Trigger: "Targeting blogs worked great for us for 18 months — got 40k users. But now CAC is climbing and new blog partnerships aren't producing the same volume. Growth is flattening."
Process: Recognize this as the Law of Shitty Click-Throughs — the channel is saturating. Run Bullseye again, this time weighted by the test data already accumulated. (1) Brainstorm with the history in mind: "We know blog-style content works — which channels amplify that?" (2) Rank: Column A = PR (media coverage amplifies existing content), Content Marketing (owned publication), Community Building (turning blog readers into evangelists). (3) Inner circle: PR, Content, Community. (4) Tests: 1 HARO pitch per day for 30 days, launch own publication with 8 articles, seed community in Slack. (5) PR produced biggest lift — TechCrunch feature = 8,000 new users in 48 hours.
Output: Channel rotation handoff from Targeting Blogs → PR, with Content Marketing as supporting channel for PR amplification.
## References
- For the complete list of 19 traction channels with descriptions, see [references/traction-channels.md](references/traction-channels.md)
- For detection and counter-tactics for the three founder bias types, see [references/channel-biases.md](references/channel-biases.md)
- For the Mint case study showing Bullseye in action from 0 → 1M users, see [references/mint-case-study.md](references/mint-case-study.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Traction: A Startup Guide to Getting Customers by Gabriel Weinberg and Justin Mares.
## Related BookForge Skills
This skill is the entry point for the Traction methodology. Install related skills from ClawhHub:
- `clawhub install bookforge-startup-traction-strategy-by-phase` — Matches channels to your current growth phase (I/II/III)
- `clawhub install bookforge-traction-channel-testing` — Designs cheap tests for inner-circle channels
- `clawhub install bookforge-startup-critical-path-planning` — Integrates channel selection into startup milestone planning
Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/channel-biases.md
# The Three Founder Channel Biases
These biases systematically narrow the brainstorm set. Detect them during Step 1 of the Bullseye Framework and force an idea for the affected channel anyway.
## 1. Invisible Bias
**Symptom:** The founder doesn't even think of the channel. It's not on their mental map of "marketing."
**Example:** Engineers often have no mental slot for "Speaking Engagements" — they've never given a talk and don't imagine it as a customer acquisition channel.
**Detection question:** "Have you considered [channel]?" If the answer is a blank stare or a puzzled "... no?", this is invisible bias.
**Counter:** Walk through every single channel name during the brainstorm. Force one idea for each, even if it's bad.
## 2. Negative Bias
**Symptom:** The founder has a personal negative reaction to a channel — they hate it or tried it once and it failed.
**Example:** "I hate talking on the phone, so cold calling is out." "We tried Facebook ads and lost money, so social ads are dead for us."
**Detection question:** "What's your reaction to [channel]?" If the answer is visceral ("ugh", "I hate that"), this is negative bias.
**Counter:** Decouple channel from the founder's preferences. The founder isn't the customer. "Just because you hate talking on the phone doesn't mean your customers do." Past failure in a channel doesn't mean the channel is broken — the test design might have been wrong, the product might have been wrong, or the channel might be right *now* even if it was wrong then.
## 3. Schlep Bias
**Symptom:** The founder avoids channels that are manual, unsexy, or high-effort — especially when they feel "beneath" the company's self-image.
**Example:** "We're a modern SaaS, we don't do trade shows." "BD is too slow, we want scalable." "Community building is for consumer products, not enterprise."
**Detection question:** "Why not [channel]?" If the answer is about effort, image, or sexiness, this is schlep bias.
**Counter:** Jason Cohen's framing: "If your competition refuses to try these channels, that's even more reason to go try them — it's almost a competitive advantage." The schleppy channels are often uncrowded exactly because of this bias.
## The Meta-Bias
All three biases converge on the same failure: **the founder reaches for familiar, comfortable, or exciting channels first, ignoring the channels that might actually work best.** The Bullseye brainstorm is explicitly designed to counter this by forcing every channel into consideration before ranking.
## Source
Chapter 1 ("Traction Channels") and Chapter 2 ("The Bullseye Framework") of *Traction* by Gabriel Weinberg and Justin Mares.
FILE:references/mint-case-study.md
# Case Study: Mint — From 0 to 1 Million Users in 6 Months
The canonical example of the Bullseye Framework applied to a real startup. Documented by Noah Kagan, Mint's early marketing lead.
## Context
- **Company:** Mint.com, personal finance management
- **Stage:** Pre-launch, no customers
- **Traction goal:** 100,000 users in the first 6 months after launch
- **Outcome:** Exceeded goal — reached 1 million users in 6 months. Acquired by Intuit for $170M.
## Bullseye Step 1 — Brainstorm
Kagan and team brainstormed ideas across all 19 channels. They identified channels where they had plausible angles given their target audience (personal finance users) and early-stage budget.
## Bullseye Step 2/3 — Rank and Prioritize
**Inner Circle (Column A, 3 channels):**
1. **Targeting Blogs** — Mid-level personal finance bloggers had engaged, on-topic audiences. Mint's product was a natural fit.
2. **Public Relations (PR)** — Finance is a trusted-recommendation category. Media coverage would drive signups.
3. **Search Engine Marketing (SEM)** — Direct demand fulfillment for search terms like "budget tracker" and "personal finance software".
Channels in Columns B/C included Viral Marketing, Content Marketing, Sales, and others — not dismissed, but lower priority for this specific stage.
## Bullseye Step 4 — Test
**Targeting Blogs test:** Sponsored a small personal finance newsletter. Cost: low hundreds of dollars. Measured signup conversion.
**PR test:** Reached out to Suze Orman (high-profile personal finance celebrity) with a personal pitch.
**SEM test:** Placed Google ads on category terms. Measured CAC and signup quality.
## Bullseye Step 5 — Focus
Targeting Blogs produced the strongest signal. Kagan doubled down:
- **VIP Access tactic:** Offered pre-launch priority to blog readers in exchange for a badge on the blog.
- **Sponsorship tactic:** Paid niche bloggers for sponsored placement.
- **Guest posting:** Wrote personal finance content for mid-level blogs.
**Result of Step 5:** 40,000 users before launch — from targeting blogs alone.
## Bullseye Repeat
After targeting blogs began to saturate (the Law of Shitty Click-Throughs kicking in), Kagan ran Bullseye *again*. This time, the test data from phase one informed the next inner circle.
**New inner circle:** PR moved into Column A because the momentum from blogs created newsworthy milestones. PR became the primary channel for the next growth stage.
**Result of the second cycle:** Mint crossed 1 million users in 6 months post-launch.
## Key Lessons
1. **Bullseye is iterative, not one-shot.** Mint ran it at least twice in the first year.
2. **The winning channel is unpredictable before testing.** Mint's team had a hypothesis about SEM but blogs outperformed it.
3. **Test data accumulates.** The second Bullseye cycle wasn't a cold start — it used data from the first cycle to rank channels better.
4. **Focus produces compounding returns.** Mint got 40k users from just one channel (blogs) because they doubled down rather than hedging.
## Source
Chapter 2 ("The Bullseye Framework") of *Traction* by Gabriel Weinberg and Justin Mares. Noah Kagan's account of Mint's early growth is presented as the canonical Bullseye worked example.
FILE:references/traction-channels.md
# The 19 Traction Channels
A reference list of every channel the Bullseye Framework brainstorms across. Each entry includes a one-line definition and typical fit signals.
1. **Viral Marketing** — Getting existing users to refer others to the product. Fit: products with inherent sharing value or network effects.
2. **Public Relations (PR)** — Traditional media coverage (news, newspapers, magazines). Fit: Phase II+ with newsworthy milestones.
3. **Unconventional PR** — Publicity stunts and extreme customer appreciation. Fit: brand-building, memorable launches.
4. **Search Engine Marketing (SEM)** — Paid ads on Google/Bing. Fit: existing search demand for category terms.
5. **Social and Display Ads** — Paid ads on Facebook, Twitter, LinkedIn, display networks. Fit: visual/brand-driven products, clear demographic targeting.
6. **Offline Ads** — TV, radio, print, billboards, direct mail. Fit: mass-market, later-stage, broad demographic.
7. **Search Engine Optimization (SEO)** — Organic ranking in search engines. Fit: existing search demand, long time horizon.
8. **Content Marketing** — Blog, newsletter, podcast, video as acquisition channel. Fit: audience that reads, compounding over time.
9. **Email Marketing** — Lifecycle emails for acquisition, activation, retention, revenue. Fit: all stages; pairs with every other channel.
10. **Engineering as Marketing** — Free tools that generate leads (calculators, widgets, grader tools). Fit: technical team, well-defined customer problem.
11. **Targeting Blogs** — Sponsoring, guest posting, or relationship-building with niche blogs. Fit: Phase I, defined audience, limited reach blogs.
12. **Business Development (BD)** — Partnerships that exchange value (not dollars). Fit: when partner brings distribution, inventory, or brand.
13. **Sales** — Direct outreach, qualification, closing. Fit: enterprise, high-price products, products requiring consultation.
14. **Affiliate Programs** — Paying others a cut for driving sales or leads. Fit: defined customer value, existing affiliate ecosystem.
15. **Existing Platforms** — Leveraging platforms with large user bases (App Stores, browser extensions, social networks). Fit: products that complement a big platform's gap.
16. **Trade Shows** — Industry events where vendors meet prospects. Fit: B2B, enterprise, industries that gather at expos.
17. **Offline Events** — Running or sponsoring meetups, conferences. Fit: community-driven products, local scaling.
18. **Speaking Engagements** — Getting the founder/team on stage at relevant events. Fit: thought leadership, founder-led sales.
19. **Community Building** — Investing in relationships among users so they recruit others. Fit: products that connect people or share a mission.
## Source
Chapter 1 ("Traction Channels") of *Traction* by Gabriel Weinberg and Justin Mares.
Prioritize which assumptions to validate first and produce focused learning goals before customer conversations — classifying risks as product risk versus ma...
---
name: question-importance-prioritizer
description: Prioritize which assumptions to validate first and produce focused learning goals before customer conversations — classifying risks as product risk versus market risk. Use this skill whenever the user has many assumptions or unknowns and needs to decide which to test first, wants to identify the 3 most important learning goals for their next conversation batch, needs to figure out what the riskiest parts of their business idea are, wants to separate must-validate assumptions from safe ones, is preparing strategic learning goals but not the specific interview questions, or suspects they are avoiding the scary questions that actually matter — even if they don't mention "prioritization" or "learning goals." Do NOT use this skill to write or rewrite the actual conversation questions (use conversation-question-designer) or to analyze notes from a completed conversation (use conversation-data-quality-analyzer).
version: 1.0.0
homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/the-mom-test/skills/question-importance-prioritizer
metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}}
status: verified
source-books:
- id: the-mom-test
title: "The Mom Test"
authors: ["Rob Fitzpatrick"]
chapters: [3]
tags: [customer-discovery, question-prioritization, learning-goals, risk-classification, pre-conversation-planning]
depends-on: []
execution:
tier: 1
mode: hybrid
inputs:
- type: document
description: "Product idea description and list of assumptions or unknowns to validate"
tools-required: [Read, Write]
tools-optional: []
mcps-required: []
environment: "Any agent environment with file read/write access."
---
# Question Importance Prioritizer
## When to Use
You need to decide what to learn before customer conversations — not just which questions pass quality rules, but which questions actually matter for your business survival. Typical situations:
- The user has many assumptions to validate and needs to prioritize which 3 to focus on next
- The user is preparing for a batch of customer conversations and needs focused learning goals
- The user has been having conversations but feels stuck because they are asking safe, comfortable questions
- The user needs to determine whether their biggest risks can even be validated through conversations (product risk vs market risk)
- The user wants to identify the "scary questions" they have been avoiding
- The user has a long list of unknowns and does not know where to start
Before starting, verify:
- Does the user have a product idea or business concept? (If not, this skill cannot help yet)
- Does the user have at least a rough sense of who their customers might be? (Different customer types need different learning goals)
**Mode: Hybrid** — The agent produces the prioritized learning goals and prepared questions. The human conducts the actual conversations.
## Context & Input Gathering
### Required Context (must have — ask if missing)
- **Product idea or business concept:** What is the user building or exploring? This is the foundation for identifying risks and learning goals.
- Check prompt for: product descriptions, startup ideas, feature concepts, problem statements
- Check environment for: `product-idea.md`, `README.md`, pitch documents
- If still missing, ask: "What product or business idea are you working on? A few sentences describing what it does and who it is for."
- **Assumptions or unknowns to validate:** What does the user believe but has not yet proven? This is the raw material for prioritization.
- Check prompt for: hypotheses, assumptions lists, "I think...", "I believe...", "I assume...", risk lists
- Check environment for: `learning-log.md`, `assumptions.md`, previous conversation notes
- If still missing, ask: "What are the key assumptions your business depends on? List everything you believe to be true but have not yet validated — about your customers, the problem, the market, pricing, distribution, anything."
### Observable Context (gather from environment)
- **Customer segment:** Who is the user targeting? Different segments need different learning goals.
- Look for: `customer-segments.md`, persona descriptions, target market references
- If unavailable: ask "Who are your target customers? Be as specific as you can."
- **Current stage:** How far along is the user? Pre-idea, pre-product, has a prototype, has paying customers?
- Look for: references to prototypes, MVPs, revenue, launch dates
- If unavailable: assume pre-product (exploring the problem space)
- **Previous conversation learnings:** What has already been validated or invalidated?
- Look for: `conversation-notes/`, `learning-log.md`
- If unavailable: assume first round of conversations
### Default Assumptions
- If no customer type specified, design learning goals generic enough for early exploration but note this limitation
- If no stage specified, assume pre-product (learning phase)
- If no prior conversations, assume all assumptions are unvalidated
### Sufficiency Threshold
```
SUFFICIENT when ALL of these are true:
- Product idea or business concept is known
- At least 3 assumptions or unknowns are identified
- Customer type is known or defaulted
PROCEED WITH DEFAULTS when:
- Product idea is known but assumptions are vague ("I'm not sure what I don't know")
- Customer type is approximate ("probably restaurant owners")
MUST ASK when:
- No product idea at all
- User provides questions but no context on the business they are building
```
## Process
### Step 1: Surface All Business Risks
**ACTION:** List every assumption the business depends on — both the ones the user stated and the ones they may have missed. Use two diagnostic questions to uncover hidden risks:
1. "If this company were to fail, why would it have happened?" — list every plausible failure reason
2. "What would have to be true for this to be a huge success?" — list every condition required
**WHY:** Most founders focus on the risks they find interesting (usually the product or technology) and ignore the ones that scare them (usually the market, pricing, or distribution). The two diagnostic questions systematically surface hidden risks that the user is unconsciously avoiding. The most important questions to ask customers are precisely the ones that feel most uncomfortable.
**IF** the user provided a list of assumptions, review it against the diagnostic questions and add any missing risks
**IF** the user did not provide assumptions, generate the risk list entirely from the diagnostic questions
**OUTPUT:** A comprehensive list of business risks, grouped loosely by area (customer/problem, market/pricing, product/technology, distribution/growth, team/operations).
### Step 2: Classify Each Risk as Product Risk or Market Risk
**ACTION:** For each risk from Step 1, classify it into one of two categories:
| Risk Type | Definition | Key Questions | Can Conversations Validate? |
|-----------|-----------|---------------|---------------------------|
| **Market risk** | Do they want it? Will they pay? Are there enough of them? | Demand, willingness to pay, market size, problem severity | Yes — customer conversations are the primary validation tool |
| **Product risk** | Can I build it? Can I grow it? Will they keep using it? | Technical feasibility, scalability, retention, network effects, critical mass | Limited — you need to build something to prove these |
**WHY:** This classification determines how much weight to give conversation-based validation for each risk. If the user's biggest risk is product-side (like building a marketplace that needs critical mass, or a video game that needs to be fun), customer conversations alone cannot validate it — the user will need to start building earlier with less certainty. Mistaking product risk for market risk leads to months of conversations that "validate" obvious things (e.g., asking farmers if they want more money, asking bar owners if they want more customers).
**Detection test for product risk masquerading as market risk:** If customer responses consistently sound like "Yes, if you can actually build that, I would pay" — the risk is in the product, not the market. The customer is restating the obvious.
**IF** the majority of risks are product-side, warn the user: "Your biggest unknowns are about whether you can build and grow this, not whether people want it. Customer conversations will give you a starting point, but you will need to start building earlier to validate the core risks. Focus conversations on understanding the problem depth and current workarounds, not on confirming demand."
**OUTPUT:** Each risk annotated with its type (market/product) and whether conversations can validate it.
### Step 3: Prioritize into the Top 3 Learning Goals
**ACTION:** From the classified risk list, select the 3 most important learning goals for the next batch of conversations. Prioritize using these criteria:
1. **Business-criticality:** Could this risk, if wrong, kill the entire business? Risks that would require a complete pivot outrank risks that would require a feature adjustment.
2. **Current uncertainty:** How much evidence does the user already have? Prioritize the murkiest unknowns — the ones where the user has the least data.
3. **Conversational reach:** Can customer conversations actually answer this? Deprioritize pure product risks that need building, not talking.
4. **Scariness:** Is this a question the user has been avoiding? If a question makes the user uncomfortable, that is a signal it is important. A question you are not terrified of is probably not important enough.
**WHY:** Without prioritization, conversations wander across too many topics and produce shallow data on everything, deep data on nothing. Three is the right number because it is small enough to focus a conversation but large enough to make each conversation worthwhile. Choose the murkiest and most important questions — they will give you the firmest footing and clearest sense of direction for the next batch.
**Scary question test:** Review the final list and verify that at least one learning goal makes the user uncomfortable. If all three feel safe and easy to ask about, the list is wrong — the user is avoiding the hard questions. Flag this explicitly: "None of these learning goals seem scary. What question are you most afraid to ask? That one probably belongs on this list."
**IF** the user has multiple customer types, create a separate list of 3 for each type — learning goals differ by audience
**IF** this is not the first batch of conversations, review previous learnings and update: drop validated goals, promote the next murkiest unknowns
**OUTPUT:** A numbered list of exactly 3 learning goals, each with:
- The learning goal stated as a concrete question to answer
- Why it matters (what changes if the answer is negative)
- The risk type (market or product)
- A scariness rating (comfortable / uncomfortable / terrifying)
### Step 4: Check for Premature Zoom
**ACTION:** Review each learning goal and assess whether it assumes something that has not yet been validated. Apply the premature zoom diagnostic:
- Does this goal zoom into a specific problem area without first confirming that area matters to the customer?
- If you ask about this topic, will the customer give you detailed answers just because you asked — regardless of whether they actually care?
- Would the customer have raised this topic on their own if you asked broad questions about their life?
**WHY:** Premature zoom is one of the most dangerous patterns in customer discovery. When you ask "What is your biggest problem with X?", you assume X matters. The person gives you an answer because you asked, not because they care. This creates data that looks like validation but is actually worthless. Even if you learn everything there is to know about a trivial problem, you still do not have a business. The fix is to start broad and only zoom in when the customer independently signals that this area is a top priority for them.
**FOR EACH** learning goal:
- **IF** the goal assumes problem importance → flag it and add a broader "does this even matter?" goal that should come first
- **IF** the goal is already about confirming importance → mark it as properly scoped
- **IF** previous conversations have already confirmed importance → mark it as safe to zoom
**"Does-this-problem-matter" diagnostic questions** (use these to validate importance before zooming in):
- "How seriously do you take [area]?"
- "Do you make money from it?"
- "Have you tried making more money from it?"
- "How much time do you spend on it each week?"
- "Do you have any major aspirations for [area]?"
- "Which tools and services do you use for it?"
- "What are you already doing to improve this?"
- "What are the 3 big things you are trying to fix or improve right now?"
**OUTPUT:** Each learning goal annotated with its zoom-level safety status and, where needed, a broader prerequisite question.
### Step 5: Produce the Prioritized Learning Goals Deliverable
**ACTION:** Compile the final output document containing the prioritized learning goals with risk classification and prepared questions for each goal.
**WHY:** The deliverable must be immediately usable before conversations. The user should be able to glance at it and know exactly what they need to learn, why each goal matters, and which questions to ask. This is the "list of 3" that they carry into every conversation with this customer type.
**Output format:**
```markdown
# Prioritized Learning Goals
## Context
- **Product/Business:** [from input]
- **Target Customer:** [from input]
- **Stage:** [from input or default]
- **Date Prepared:** [today]
- **Batch:** [first / updated after N conversations]
## Risk Overview
- **Total risks identified:** [N]
- **Market risks (conversation-validatable):** [N]
- **Product risks (need building to validate):** [N]
- **Biggest overlooked risk:** [the one the user was probably avoiding]
## Top 3 Learning Goals
### 1. [Learning Goal as Question]
- **Risk type:** Market / Product
- **Why it matters:** [what changes if the answer is negative — be specific]
- **Scariness:** Comfortable / Uncomfortable / Terrifying
- **Zoom-level check:** [Safe to zoom / Needs importance confirmation first]
- **Prepared questions:**
- [Broad opener to confirm importance]
- [Specific past-focused depth question]
- [Commitment/severity signal question]
- **What a negative answer looks like:** [concrete signal that disproves this]
- **What a positive answer looks like:** [concrete signal that validates this]
### 2. [Learning Goal as Question]
[same structure]
### 3. [Learning Goal as Question]
[same structure]
## Questions You Might Be Avoiding
- [Scary question 1 — and why it matters]
- [Scary question 2 — and why it matters]
## Premature Zoom Warnings
- [Any goals that assume unvalidated importance, with the broader question to ask first]
## Risk Classification Summary
| Risk | Type | Conversation Can Validate? | Priority |
|------|------|---------------------------|----------|
| [risk 1] | Market | Yes | In top 3 |
| [risk 2] | Product | Limited | Deferred |
| ... | ... | ... | ... |
## Next Steps
- After this conversation batch, review which goals are answered
- Drop answered goals, promote next-murkiest unknowns
- Update this document with new top 3
```
**IF** the user provided a file path or working directory, write the output to `learning-goals.md`
**ELSE** present the output directly in the conversation
## Examples
### Scenario 1: SaaS Founder with a Long Assumption List
**Trigger:** "I'm building a tool that helps restaurant owners manage their online reviews across Google, Yelp, and TripAdvisor. Here are my assumptions: (1) Restaurant owners care about online reviews, (2) Managing multiple platforms is painful, (3) They would pay $50/month, (4) They check reviews daily, (5) Negative reviews cause real revenue loss, (6) They want AI-generated review responses, (7) They struggle to get customers to leave reviews."
**Process:**
1. Surface all risks: The user listed 7 assumptions, but diagnostic questions reveal hidden ones — distribution (how will they find this tool?), competition (existing tools like Podium?), buyer (is the owner the one managing reviews or a manager?), and time (do they have bandwidth to use yet another tool?)
2. Classify risks: Assumptions 1-5, 7 are market risks (conversationally validatable). Assumption 6 is product risk (AI quality). Distribution and competition are market risks.
3. Prioritize top 3:
- Goal 1: "Do restaurant owners actually manage reviews themselves, and is it painful enough to pay to fix?" (market risk, terrifying — could invalidate the whole idea)
- Goal 2: "What tools or workarounds are they using today, and what do they spend?" (market risk, uncomfortable — might reveal strong competitors)
- Goal 3: "How do they currently respond to negative reviews, and what is the real cost of not responding?" (market risk, comfortable — validates severity)
4. Premature zoom check: Goal 3 assumes negative reviews matter enough to act on — needs importance confirmation first
**Output (abbreviated):**
```
### 1. Do restaurant owners personally manage reviews — and is it painful enough to pay $50/month?
- Risk type: Market
- Why it matters: If owners delegate review management or don't care, there is no buyer
- Scariness: Terrifying
- Zoom-level check: Safe — this IS the importance check
- Prepared questions:
- "Walk me through what you did the last time you got a negative review online."
- "How much time do you spend on review-related tasks in a typical week?"
- "What are you currently paying for any marketing or reputation tools?"
- What a negative answer looks like: "My manager handles that" or "I don't really check them"
- What a positive answer looks like: Specific stories of time spent, emotional frustration, existing workarounds
```
---
### Scenario 2: Technical Founder with Pure Product Risk
**Trigger:** "I'm building a multiplayer mobile game where players collaborate to solve environmental puzzles. I want to validate whether people would play this. My assumptions: (1) People enjoy collaborative puzzle games, (2) Environmental themes attract players, (3) Mobile is the right platform, (4) Players will invite friends to join."
**Process:**
1. Surface risks: Diagnostic questions reveal the elephant — nearly all risk is product-side (Is it fun? Can it retain players? Can it achieve network effects for multiplayer?)
2. Classify risks: All 4 stated assumptions are product risks. "Do people enjoy collaborative puzzle games?" is like asking "Do you like having fun?" — the answer is always yes.
3. Prioritize: Warn the user that conversations cannot validate the core risks. Redirect toward the few market risks that exist: Are there enough puzzle game enthusiasts in this niche? What games do they currently play? How much do they spend on mobile games?
**Output (abbreviated):**
```
## Risk Overview
- Total risks identified: 8
- Market risks: 2 (audience size, spending habits)
- Product risks: 6 (fun factor, retention, multiplayer matchmaking, network effects, art quality, puzzle design)
- Biggest overlooked risk: Nearly all your risk is product-side. Customer conversations cannot tell you whether your game is fun. You need to build a prototype and watch people play.
### 1. Are there enough people who actively seek out collaborative puzzle games — and where do they congregate?
- Risk type: Market
- Why it matters: Even a great game fails if the target audience is too small or unfindable
- Scariness: Uncomfortable
- Prepared questions:
- "What puzzle games have you played in the last month? Tell me about the most recent session."
- "How do you discover new games? Walk me through the last game you downloaded."
- "Have you ever specifically searched for a game where you could play with friends?"
## Questions You Might Be Avoiding
- "Could I actually build a multiplayer puzzle game that is fun and retains players?" — This is your real risk, and conversations cannot answer it. Start prototyping.
```
---
### Scenario 3: Founder Updating Learning Goals After First Batch
**Trigger:** "I just finished 5 conversations about my invoice factoring tool for freelancers. I learned that freelancers definitely have cash flow problems (validated) and they mostly use spreadsheets to track invoices (validated). But I still don't know if they would trust a third party with their invoices, and I realize I never asked about pricing. What should I focus on next?"
**Process:**
1. Surface risks: Cash flow pain (validated), current tools (validated), trust with financial data (unvalidated), willingness to pay (unvalidated), plus hidden risks — do they invoice enough volume to justify a tool? Are there regulatory issues?
2. Classify: Trust and pricing are market risks. Invoice volume is market risk. Regulatory is mixed.
3. Prioritize top 3 for next batch:
- Goal 1: "Would freelancers trust a third-party service to handle their invoice payments?" (market risk, terrifying — deal-breaker if no)
- Goal 2: "How much money is stuck in late invoices per month, and what would they pay to get it faster?" (market risk, uncomfortable)
- Goal 3: "Do they invoice enough clients per month for factoring to be worthwhile?" (market risk, comfortable)
4. Note that the user explicitly identified they "never asked about pricing" — this was a scary question they avoided in the first batch
**Output (abbreviated):**
```
## Questions You Might Be Avoiding
- "Would you hand over control of your invoices to a service you found online?" — You avoided this in 5 conversations. That avoidance is a signal that this is your scariest and most important question.
- "What would you pay for this?" — You explicitly noted you avoided pricing. Ask about current spending on financial tools first, then explore willingness to pay.
```
## Key Principles
- **The questions you are avoiding are the ones you most need to ask** — Fear of bad news causes founders to ask comfortable questions that feel productive but do not de-risk anything. If you are not terrified of at least one question in every conversation, you are wasting the conversation. The cost of not asking is always higher than the cost of hearing a bad answer. One founder avoided asking lawyers about legal ambiguities and it cost half a million dollars.
- **Product risk and market risk require different validation methods** — When the customer says "If you can build it, I will pay," that is not validation — it is restating the obvious. Customer conversations validate market risk (Do they want it? Will they pay? Are there enough of them?). Product risk (Can I build it? Can I grow it? Will they keep using it?) requires building. Misclassifying your risk type leads to months of conversations that prove things nobody doubted.
- **Start broad before zooming in — always** — Most people have many problems they will happily discuss if you ask about them. Zooming into your specific problem area before confirming it is a top priority creates false validation. The person answers your detailed questions because you asked, not because they care. Start with "What are the big things you are trying to fix right now?" and only zoom in when they raise your area themselves. If they do not mention it unprompted, they probably do not care enough to pay for a solution.
- **Three learning goals is the right number** — Too few and each conversation covers too little ground. Too many and the conversation scatters across topics without going deep on any. Three goals lets you focus while remaining flexible enough to follow interesting threads. After each batch of conversations, drop answered goals and promote the next murkiest unknowns.
- **Lukewarm signals are more reliable than enthusiastic ones** — When someone says "That is pretty neat" or "I am not so sure about that," the instinct is to pitch harder until they say something nice. Resist this. A lukewarm response is perfectly reliable information — this person does not care enough. You cannot build a business on a lukewarm response. The only thing you gain from "convincing" them is a false positive.
## References
- For designing specific questions that pass customer conversation quality rules, use the `conversation-question-designer` skill
- For narrowing broad customer segments into specific, findable who-where pairs, use the `customer-segment-slicer` skill
- For the complete "does-this-problem-matter" diagnostic question set and risk classification details, see [risk-classification-guide.md](references/risk-classification-guide.md)
## License
This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — The Mom Test by Rob Fitzpatrick.
## Related BookForge Skills
This skill is standalone. Browse more BookForge skills: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)
FILE:references/risk-classification-guide.md
# Risk Classification Guide
## Product Risk vs Market Risk
Every business faces risks in two fundamental categories. Correctly classifying your risks determines whether customer conversations are the right validation tool.
### Market Risk
**Definition:** Do they want it? Will they pay? Are there enough of them?
**Examples:**
- SaaS tools solving known pain points
- Services addressing recognized problems
- Products entering established categories with a differentiated approach
**Validation method:** Customer conversations are the primary tool. Ask about current behavior, workarounds, spending, and severity.
**Key signal:** When customers describe their current problem, their workarounds, and what they spend — that is market risk data you can act on.
### Product Risk
**Definition:** Can I build it? Can I grow it? Will they keep using it?
**Examples:**
- Video games (Is it fun? Will people play it repeatedly?)
- Marketplaces needing critical mass (Can you get enough supply AND demand?)
- Platforms needing network effects (Will users invite others?)
- Ad-supported models (Can you get enough traffic?)
- Technically complex products (Can the technology actually work?)
**Validation method:** You need to build something. Conversations can give you a starting point (understanding problem depth, confirming willingness to switch), but the core risk requires a prototype, beta, or proof of concept.
**Key signal:** When customer responses consistently sound like "Yes, if you can actually build/do that, I would definitely pay" — the risk is in the product, not the market. The customer is restating the obvious.
### Detection Test
Ask yourself: "Is the customer telling me something I did not already know, or are they confirming what everyone would say?"
- "Would you like more money?" — Everyone says yes. This validates nothing. Risk is product-side.
- "Would you switch trackers if something cheaper and more effective was available?" — Same as asking if they want more money. Obvious yes.
- "Would you pay if you could send customers on demand to your bar?" — Bars obviously want more customers. The risk is whether you can amass a consumer audience.
### Mixed Risk Situations
Most businesses have both types. Do not overlook either one.
**Farm fertility monitor example:** The founder spent 3 months on customer conversations asking farmers if they would switch to a better tracker. Farmers said "If you can build what you say, I will equip my whole herd." This sounded like validation but was actually product risk restated as enthusiasm. The real question: Can you build hardware that works reliably on farms?
**Nightclub app example:** Founders validated that bar owners want more customers (obvious) and consumers like cheap drinks (obvious). But the real risk — amassing enough users on both sides of the marketplace — was never tested through conversations.
## "Does-This-Problem-Matter" Diagnostic Questions
Use these questions to verify that a problem area is genuinely important to the person before zooming into details:
1. "How seriously do you take [area]?"
2. "Do you make money from it?"
3. "Have you tried making more money from it?"
4. "How much time do you spend on it each week?"
5. "Do you have any major aspirations for [area]?"
6. "Which tools and services do you use for it?"
7. "What are you already doing to improve this?"
8. "What are the 3 big things you are trying to fix or improve right now?"
These questions are generic by design. They give signals you can anchor on and dig around. The bulk of them are about finding out whether the person is taking this space seriously — are they spending money or making money? Is it in their top 3? Are they actively looking for solutions?
## Pre-Meeting Risk Discovery Questions
Two questions to unearth hidden risks before conversations:
1. **"If this company were to fail, why would it have happened?"** — Forces you to enumerate all failure modes, not just the one you find most interesting.
2. **"What would have to be true for this to be a huge success?"** — Surfaces the necessary conditions that you might be taking for granted.
These questions come from strategic planning (Lafley and Martin) and are useful both for the founding team during preparation and for guiding which risks to prioritize in conversations.
## The Premature Zoom Problem
### What It Is
Asking detailed questions about a specific problem area before confirming that area actually matters to the person. This creates data that looks like validation but is worthless.
### Why It Happens
Most people have lots of problems they do not actually care enough about to fix, but which they will happily tell you the details of if you ask. When you zoom in on your area immediately, you get detailed answers — not because the problem matters, but because you asked.
### How to Detect It
- Would the customer have raised this topic on their own?
- Are you assuming this problem area is important, or has the customer demonstrated importance through their behavior?
- If you asked "What are the 3 big things you are trying to fix right now?", would your area make their list?
### How to Fix It
Start broad: "What are your big goals and focuses right now?" Only zoom into your specific area when the customer independently raises it. If they do not mention it, it is probably not a top priority — and that is reliable, actionable information.
### The Fitness App Example
**Bad conversation:** Asks a non-exerciser about gym problems. Gets a ranking of fitness priorities. Concludes "we got a user!" — but the person never exercises and will never use the app.
**Good conversation:** Asks about life goals broadly. Fitness does not make the list. Conclusion: this person is not a customer. Moves on to find people who actually care about fitness enough to act on it.
The premature zoom is dangerous because if you are not paying attention, the bad conversation seems like it went well. You got detailed answers. You "validated" a problem. But you just led them there.
Route natural-language requests about today's news, market news, TradeAlpha news, or TradeAlpha login into the bundled TradeAlpha plugin tools. Prefer the si...
---
name: tradealpha-open-platform
description: Route natural-language requests about today's news, market news, TradeAlpha news, or TradeAlpha login into the bundled TradeAlpha plugin tools. Prefer the single router tool `tradealpha_open_platform`, and only fall back to helper tools when needed.
homepage: https://quantaccess.lxaa.top
version: 0.4.0
metadata:
{
"openclaw":
{
"emoji": "📰",
"requires": { "bins": ["node"] },
"primaryEnv": "TRADEALPHA_API_KEY",
},
}
---
# TradeAlpha开放平台
TradeAlpha开放平台:路透、彭博、川普 Truth、国内主流消息源,一网打尽。
TradeAlpha Open Platform: Reuters, Bloomberg, Trump's Truth Social, and major Chinese news sources, all in one place.
这个 skill 只负责自然语言召回和登录门控,真正执行依赖同名插件里的真实工具。不要把 `tradealpha-open-platform` 当成 tool 名调用;应优先调用插件总入口 `tradealpha_open_platform`。
## First Rule
每次用户想使用 TradeAlpha 新闻能力时,都遵守下面的固定顺序:
1. 用户想登录、获取 token、刷新 token 时,先向用户索要账号和密码
2. 调用 `tradealpha_open_platform`,并传 `intent: "login"`
3. 用户想拉新闻时,优先调用 `tradealpha_open_platform`
4. 如果 `tradealpha_open_platform` 返回 `auth_required: true`
5. 立即向用户索要账号和密码
6. 再次调用 `tradealpha_open_platform`,补上账号和密码
7. 登录成功后再重试新闻请求
如果用户提到以下任一意图,应优先触发本技能:
- 今天的新闻
- 今日新闻
- 现在的新闻
- 市场新闻
- 宏观新闻
- 路透新闻
- 彭博新闻
- Truth 新闻
- 国内新闻快讯
- 登录 TradeAlpha
- 获取 token
- 初始化或刷新 token
- 配置 TradeAlpha 权限
- 拉取实时新闻
- 按来源、分类、重要程度筛选新闻
不要说“没有 tradealpha 这个工具”。当前应优先使用的真实工具是:
- `tradealpha_open_platform`
- `tradealpha_login`(辅助)
- `tradealpha_news`(辅助)
- `tradealpha_realtime_news`(兼容别名,优先仍用 `tradealpha_news`)
## When To Use
在这些场景使用本技能:
- 用户直接说“我要今天的新闻”“帮我拉今天新闻”
- 用户直接说“帮我看市场新闻”“帮我拉彭博/路透新闻”
- 用户要先登录或初始化 token
- 用户要更新、刷新、重新获取 token
- 用户要抓取实时新闻
- 用户要按来源、重要程度、分类筛选新闻
- 用户要对比彭博、路透、Truth、国内源口径
- 用户要获取近 24 小时或指定时间段内的市场新闻
## Routing Rules
### 登录场景
如果用户要登录、初始化 token、刷新 token:
1. 向用户索要 `account` 和 `password`
2. 调用 `tradealpha_open_platform`,传 `intent: "login"`、`account`、`password`
3. 登录成功后再继续后续新闻请求
### 拉新闻场景
如果用户要新闻:
1. 直接调用 `tradealpha_open_platform`
2. 如果返回 `auth_required: false`,继续整理新闻结果
3. 如果返回 `auth_required: true`,向用户索要 `account` 和 `password`
4. 再次调用 `tradealpha_open_platform`,携带 `account` 和 `password`
5. 如果用户只想单独登录,也可以调用 `tradealpha_open_platform` 并传 `intent: "login"`
### 新闻工具常用参数
- `intent`
- `timeframe`
- `start_time`
- `end_time`
- `source`
- `category`
- `level`
- `page`
- `page_size`
## Runtime Rules
- 先走插件总入口 `tradealpha_open_platform`,不要回退到 shell 脚本
- 对“今天新闻”“今日新闻”“拉新闻”这类自然语言,默认视为要用本 skill
- 如果工具返回 `auth_required: true`,必须先登录,不能跳过
- 登录前不要假设用户已经有 token
- 返回结果是 JSON,先读 `details` / JSON 再总结给用户
- 不要在回复里回显用户密码或 token
- 新闻通常存在 `0-5` 分钟客观延迟
FILE:dist/src/auth.d.ts
export interface StoredTradeAlphaConfig {
apiToken?: string;
accessToken?: string | null;
tokenType?: string | null;
account?: string;
user?: unknown;
savedAt?: string;
}
export declare function getTradeAlphaConfigPath(): string;
export declare function readStoredTradeAlphaConfig(): StoredTradeAlphaConfig | null;
export declare function getTradeAlphaApiToken(): string | null;
FILE:dist/src/auth.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getTradeAlphaConfigPath = getTradeAlphaConfigPath;
exports.readStoredTradeAlphaConfig = readStoredTradeAlphaConfig;
exports.getTradeAlphaApiToken = getTradeAlphaApiToken;
const node_fs_1 = __importDefault(require("node:fs"));
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const CONFIG_PATH = node_path_1.default.join(node_os_1.default.homedir(), ".config", "tradealpha-open-platform", "config.json");
function getTradeAlphaConfigPath() {
return CONFIG_PATH;
}
function readStoredTradeAlphaConfig() {
if (!node_fs_1.default.existsSync(CONFIG_PATH)) {
return null;
}
try {
const raw = node_fs_1.default.readFileSync(CONFIG_PATH, "utf8");
return JSON.parse(raw);
}
catch {
return null;
}
}
function getTradeAlphaApiToken() {
const envToken = process.env.TRADEALPHA_API_KEY?.trim();
if (envToken) {
return envToken;
}
const storedConfig = readStoredTradeAlphaConfig();
const storedToken = storedConfig?.apiToken?.trim();
return storedToken || null;
}
//# sourceMappingURL=auth.js.map
FILE:dist/src/index.d.ts
/**
* TradeAlpha Open Platform - OpenClaw Skill
*
* Aggregates major global and Chinese news sources for market intelligence.
*/
export interface ToolResult {
success: boolean;
data?: unknown;
error?: string;
}
export interface SkillTool {
name: string;
description: string;
execute: (args: Record<string, unknown>) => Promise<ToolResult>;
}
export declare const tools: SkillTool[];
declare const _default: {
metadata: {
name: string;
title: string;
description: string;
descriptionZh: string;
descriptionEn: string;
version: string;
};
tools: SkillTool[];
};
export default _default;
FILE:dist/src/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.tools = void 0;
const news_1 = require("./news");
const SKILL_NAME = "TradeAlpha开放平台";
const SKILL_DESCRIPTION_ZH = "路透、彭博、川普Truth、国内主流消息源,一网打尽";
const SKILL_DESCRIPTION_EN = "Reuters, Bloomberg, Trump's Truth Social, and major Chinese news sources, all in one place.";
const getRealtimeNewsTool = {
name: "get-realtime-news",
description: "Fetches real-time news from Reuters, Bloomberg, Truth Social, research reports, and Chinese mainstream sources.",
execute: async (args) => {
const result = await (0, news_1.fetchRealtimeNews)(args);
if (!result.success) {
return result;
}
return {
success: true,
data: {
skill: SKILL_NAME,
...(result.data ?? {}),
filters: {
sources: news_1.realtimeNewsEnums.sources,
categories: news_1.realtimeNewsEnums.categories,
levels: news_1.realtimeNewsEnums.levels,
minNewsTime: news_1.realtimeNewsEnums.minNewsTime,
},
},
};
},
};
exports.tools = [getRealtimeNewsTool];
exports.default = {
metadata: {
name: "tradealpha-open-platform",
title: SKILL_NAME,
description: `SKILL_NAME:SKILL_DESCRIPTION_ZH SKILL_DESCRIPTION_EN`,
descriptionZh: `SKILL_NAME:SKILL_DESCRIPTION_ZH`,
descriptionEn: `TradeAlpha Open Platform: SKILL_DESCRIPTION_EN`,
version: "0.3.0",
},
tools: exports.tools,
};
//# sourceMappingURL=index.js.map
FILE:dist/src/news.d.ts
import type { ToolResult } from "./index";
export declare const REALTIME_NEWS_URL = "https://quantaccess.lxaa.top/api/v1/news/realtime_news";
declare const NEWS_SOURCES: readonly ["domestic", "truth", "bloomberg", "rtrs", "research_report"];
declare const NEWS_CATEGORIES: readonly ["政治军事", "社会", "娱乐体育", "公司", "超大型公司", "政策", "市场与货币"];
declare const NEWS_LEVELS: readonly ["很重要", "重要", "一般"];
type NewsSource = (typeof NEWS_SOURCES)[number];
type NewsCategory = (typeof NEWS_CATEGORIES)[number];
type NewsLevel = (typeof NEWS_LEVELS)[number];
export interface RealtimeNewsRequest {
start_time?: string;
end_time?: string;
source?: NewsSource;
category?: NewsCategory;
level?: NewsLevel;
page?: number;
page_size?: number;
}
export declare function buildRealtimeNewsRequest(rawArgs: Record<string, unknown>): RealtimeNewsRequest;
export declare function fetchRealtimeNews(rawArgs: Record<string, unknown>): Promise<ToolResult>;
export declare const realtimeNewsEnums: {
sources: readonly ["domestic", "truth", "bloomberg", "rtrs", "research_report"];
categories: readonly ["政治军事", "社会", "娱乐体育", "公司", "超大型公司", "政策", "市场与货币"];
levels: readonly ["很重要", "重要", "一般"];
minNewsTime: string;
};
export {};
FILE:dist/src/news.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.realtimeNewsEnums = exports.REALTIME_NEWS_URL = void 0;
exports.buildRealtimeNewsRequest = buildRealtimeNewsRequest;
exports.fetchRealtimeNews = fetchRealtimeNews;
const auth_1 = require("./auth");
exports.REALTIME_NEWS_URL = "https://quantaccess.lxaa.top/api/v1/news/realtime_news";
const MIN_NEWS_TIME = "2025-04-01 00:00:00";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const NEWS_SOURCES = [
"domestic",
"truth",
"bloomberg",
"rtrs",
"research_report",
];
const NEWS_CATEGORIES = [
"政治军事",
"社会",
"娱乐体育",
"公司",
"超大型公司",
"政策",
"市场与货币",
];
const NEWS_LEVELS = ["很重要", "重要", "一般"];
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function parseOptionalString(args, key) {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`参数 \`key\` 必须是字符串。`);
}
const trimmed = value.trim();
return trimmed || undefined;
}
function parseOptionalInteger(args, key) {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
throw new Error(`参数 \`key\` 必须是整数。`);
}
function validateEnum(value, key, allowedValues) {
if (!value) {
return undefined;
}
if (!allowedValues.includes(value)) {
throw new Error(`参数 \`key\` 取值无效,可选值为:allowedValues.join("、")。`);
}
return value;
}
function normalizeComparableTime(value) {
return DATE_ONLY_PATTERN.test(value) ? `value 00:00:00` : value;
}
function validateTimeFormat(value, key) {
if (!DATE_ONLY_PATTERN.test(value) && !DATE_TIME_PATTERN.test(value)) {
throw new Error(`参数 \`key\` 格式无效,必须为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss。`);
}
const comparable = normalizeComparableTime(value);
if (comparable < MIN_NEWS_TIME) {
throw new Error(`参数 \`key\` 不能早于 2025-04-01 00:00:00(北京时间)。`);
}
}
function validateTimeRange(startTime, endTime) {
if (!startTime || !endTime) {
return;
}
if (normalizeComparableTime(startTime) > normalizeComparableTime(endTime)) {
throw new Error("`start_time` 不能晚于 `end_time`。");
}
}
function buildRealtimeNewsRequest(rawArgs) {
const start_time = parseOptionalString(rawArgs, "start_time");
const end_time = parseOptionalString(rawArgs, "end_time");
const source = validateEnum(parseOptionalString(rawArgs, "source"), "source", NEWS_SOURCES);
const category = validateEnum(parseOptionalString(rawArgs, "category"), "category", NEWS_CATEGORIES);
const level = validateEnum(parseOptionalString(rawArgs, "level"), "level", NEWS_LEVELS);
const page = parseOptionalInteger(rawArgs, "page") ?? 1;
const page_size = parseOptionalInteger(rawArgs, "page_size") ?? 20;
if (start_time) {
validateTimeFormat(start_time, "start_time");
}
if (end_time) {
validateTimeFormat(end_time, "end_time");
}
validateTimeRange(start_time, end_time);
if (page < 1) {
throw new Error("参数 `page` 必须大于或等于 1。");
}
if (page_size < 1 || page_size > 100) {
throw new Error("参数 `page_size` 必须在 1 到 100 之间。");
}
return {
start_time,
end_time,
source,
category,
level,
page,
page_size,
};
}
function getTokenOrThrow() {
const token = (0, auth_1.getTradeAlphaApiToken)();
if (!token) {
throw new Error(`未找到 TradeAlpha token。请先运行 \`npm run login\`,或设置 \`TRADEALPHA_API_KEY\`。本地配置路径:(0, auth_1.getTradeAlphaConfigPath)()`);
}
return token;
}
async function fetchRealtimeNews(rawArgs) {
try {
const request = buildRealtimeNewsRequest(rawArgs);
const token = getTokenOrThrow();
const response = await fetch(exports.REALTIME_NEWS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer token`,
},
body: JSON.stringify({
...request,
token,
}),
});
let payload;
try {
payload = await response.json();
}
catch {
return {
success: false,
error: `新闻接口返回了非 JSON 响应,HTTP response.status。`,
};
}
if (!isRecord(payload)) {
return {
success: false,
error: "新闻接口返回了无法识别的响应结构。",
};
}
const apiResponse = payload;
if (!response.ok || apiResponse.code !== 0 || !apiResponse.data) {
const detail = apiResponse.message || `HTTP response.status`;
return {
success: false,
error: typeof apiResponse.code === "number"
? `获取新闻失败(code: apiResponse.code):detail`
: `获取新闻失败:detail`,
};
}
return {
success: true,
data: {
request,
total: apiResponse.data.total,
page: apiResponse.data.page,
pageSize: apiResponse.data.page_size,
items: apiResponse.data.list,
note: "新闻数据通常存在 0-5 分钟客观延迟。",
},
};
}
catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
exports.realtimeNewsEnums = {
sources: NEWS_SOURCES,
categories: NEWS_CATEGORIES,
levels: NEWS_LEVELS,
minNewsTime: MIN_NEWS_TIME,
};
//# sourceMappingURL=news.js.map
FILE:openclaw.plugin.json
{
"id": "tradealpha-open-platform",
"name": "TradeAlpha Open Platform",
"description": "Login-first TradeAlpha bundle plugin with a single router tool plus bundled skill for OpenClaw.",
"enabledByDefault": true,
"contracts": {
"tools": [
"tradealpha_open_platform",
"tradealpha_login",
"tradealpha_news",
"tradealpha_realtime_news"
],
"skills": [
"./skills"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
FILE:package-lock.json
{
"name": "tradealpha-open-platform",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tradealpha-open-platform",
"version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
FILE:package.json
{
"name": "tradealpha-open-platform",
"version": "0.4.0",
"description": "TradeAlpha login-first OpenClaw bundle plugin and skill: a single router tool handles login, token refresh, and realtime news from Reuters, Bloomberg, Truth Social, research alerts, and major Chinese news sources.",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"openclaw": {
"extensions": [
"./plugin/index.mjs"
]
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"login": "node scripts/login.js",
"news": "node scripts/get-realtime-news.js"
},
"keywords": [
"openclaw",
"clawhub",
"ai-skill",
"tradealpha",
"news",
"markets"
],
"license": "MIT",
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0"
}
}
FILE:plugin/index.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageRoot = path.resolve(__dirname, "..");
const LOGIN_TOOL_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
account: {
type: "string",
description: "TradeAlpha 登录账号,通常是手机号或用户名。",
},
password: {
type: "string",
description: "TradeAlpha 登录密码。",
},
},
};
const NEWS_TOOL_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
intent: {
type: "string",
enum: ["login", "news"],
description: "要执行的动作。默认是 news;显式传 login 时执行登录。",
},
timeframe: {
type: "string",
enum: ["today", "latest"],
description: "快捷时间范围。today 表示今天,latest 表示默认近 24 小时。",
},
account: {
type: "string",
description: "TradeAlpha 登录账号。用于首次登录或 token 失效后自动补登录。",
},
password: {
type: "string",
description: "TradeAlpha 登录密码。用于首次登录或 token 失效后自动补登录。",
},
start_time: {
type: "string",
description: "开始时间,格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss。",
},
end_time: {
type: "string",
description: "结束时间,格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss。",
},
source: {
type: "string",
enum: ["domestic", "truth", "bloomberg", "rtrs", "research_report"],
description: "新闻源。",
},
category: {
type: "string",
enum: ["政治军事", "社会", "娱乐体育", "公司", "超大型公司", "政策", "市场与货币"],
description: "新闻分类。",
},
level: {
type: "string",
enum: ["很重要", "重要", "一般"],
description: "重要程度。",
},
page: {
type: "integer",
minimum: 1,
description: "页码,默认 1。",
},
page_size: {
type: "integer",
minimum: 1,
maximum: 100,
description: "每页条数,默认 20,最大 100。",
},
},
};
function jsonResult(payload) {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}
function resolvePackageRoot(api) {
return api?.rootDir || packageRoot;
}
function resolveScriptPath(api, relativePath) {
return path.join(resolvePackageRoot(api), relativePath);
}
function pickNewsArgs(rawParams) {
if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) {
return {};
}
const picked = {};
for (const key of [
"timeframe",
"start_time",
"end_time",
"source",
"category",
"level",
"page",
"page_size",
]) {
if (Object.hasOwn(rawParams, key)) {
picked[key] = rawParams[key];
}
}
return picked;
}
function resolveIntent(rawParams) {
if (!rawParams || typeof rawParams !== "object" || Array.isArray(rawParams)) {
return "news";
}
if (rawParams.intent === "login") {
return "login";
}
return "news";
}
function hasCredentials(rawParams) {
return Boolean(
rawParams &&
typeof rawParams === "object" &&
!Array.isArray(rawParams) &&
typeof rawParams.account === "string" &&
rawParams.account.trim() !== "" &&
typeof rawParams.password === "string" &&
rawParams.password.trim() !== "",
);
}
function normalizeTodayTimeRange(newsArgs) {
if (newsArgs.timeframe !== "today") {
const { timeframe, ...rest } = newsArgs;
return rest;
}
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const date = `year-month-day`;
const time = `String(now.getHours()).padStart(2, "0"):String(now.getMinutes()).padStart(2, "0"):String(now.getSeconds()).padStart(2, "0")`;
const { timeframe, start_time, end_time, ...rest } = newsArgs;
return {
...rest,
start_time: start_time ?? `date 00:00:00`,
end_time: end_time ?? `date time`,
};
}
async function runJsonNodeScript(scriptPath, scriptArgs) {
try {
const { stdout, stderr } = await execFileAsync(process.execPath, [scriptPath, ...scriptArgs], {
cwd: packageRoot,
env: process.env,
maxBuffer: 2 * 1024 * 1024,
});
return parseScriptJson(stdout || stderr);
} catch (error) {
const stdout = typeof error?.stdout === "string" ? error.stdout : "";
const stderr = typeof error?.stderr === "string" ? error.stderr : "";
const combined = [stdout, stderr].filter(Boolean).join("\n").trim();
try {
return parseScriptJson(combined);
} catch {
return {
success: false,
error: combined || (error instanceof Error ? error.message : String(error)),
};
}
}
}
function parseScriptJson(rawOutput) {
const trimmed = rawOutput.trim();
if (!trimmed) {
throw new Error("脚本没有返回内容。");
}
return JSON.parse(trimmed);
}
async function runTradeAlphaLogin(api, rawParams) {
const missingFields = [];
const rawAccount = rawParams?.account;
const rawPassword = rawParams?.password;
const account =
typeof rawAccount === "string" && rawAccount.trim() !== ""
? rawAccount.trim()
: null;
const password =
typeof rawPassword === "string" && rawPassword.trim() !== ""
? rawPassword.trim()
: null;
if (!account) {
missingFields.push("account");
}
if (!password) {
missingFields.push("password");
}
if (missingFields.length > 0) {
return {
success: false,
auth_required: true,
next_action: "provide_credentials",
token_source: "none",
missing_fields: missingFields,
message: "请先向用户索要 TradeAlpha 账号和密码,再调用登录。",
};
}
const scriptPath = resolveScriptPath(api, "scripts/login.js");
return await runJsonNodeScript(scriptPath, [
"--account",
account,
"--password",
password,
"--json",
]);
}
async function runTradeAlphaNews(api, rawParams) {
const scriptPath = resolveScriptPath(api, "scripts/get-realtime-news.js");
const normalizedArgs = normalizeTodayTimeRange(pickNewsArgs(rawParams));
return await runJsonNodeScript(scriptPath, [JSON.stringify(normalizedArgs)]);
}
function createTradeAlphaLoginTool(api) {
return {
name: "tradealpha_login",
label: "TradeAlpha Login",
description:
"Log in to TradeAlpha Open Platform with account and password, then persist the returned user.api_token locally. Use this first whenever TradeAlpha token status is unknown, missing, or expired.",
parameters: LOGIN_TOOL_SCHEMA,
execute: async (_toolCallId, rawParams) => {
return jsonResult(await runTradeAlphaLogin(api, rawParams));
},
};
}
function createTradeAlphaOpenPlatformTool(api) {
return {
name: "tradealpha_open_platform",
label: "TradeAlpha Open Platform",
description:
"Single entrypoint for TradeAlpha. Use this for today's news, market news, or login. It auto-checks auth, asks for credentials when missing, and can log in before retrying the news request.",
parameters: NEWS_TOOL_SCHEMA,
execute: async (_toolCallId, rawParams) => {
const intent = resolveIntent(rawParams);
if (intent === "login") {
return jsonResult(await runTradeAlphaLogin(api, rawParams));
}
let newsPayload = await runTradeAlphaNews(api, rawParams);
if (newsPayload?.auth_required === true && hasCredentials(rawParams)) {
const loginPayload = await runTradeAlphaLogin(api, rawParams);
if (loginPayload?.success !== true) {
return jsonResult(loginPayload);
}
newsPayload = await runTradeAlphaNews(api, rawParams);
if (newsPayload && typeof newsPayload === "object") {
newsPayload.login = {
success: true,
message: "已自动完成登录并重试新闻请求。",
};
}
}
return jsonResult(newsPayload);
},
};
}
function createTradeAlphaNewsTool(api, name, label, description) {
return {
name,
label,
description,
parameters: NEWS_TOOL_SCHEMA,
execute: async (_toolCallId, rawParams) => {
return jsonResult(await runTradeAlphaNews(api, rawParams));
},
};
}
export default {
id: "tradealpha-open-platform",
name: "TradeAlpha Open Platform Plugin",
description:
"Registers TradeAlpha login-first news tools for OpenClaw.",
version: "0.4.0",
register(api) {
api.registerTool(createTradeAlphaOpenPlatformTool(api));
api.registerTool(createTradeAlphaLoginTool(api));
api.registerTool(
createTradeAlphaNewsTool(
api,
"tradealpha_news",
"TradeAlpha News",
"Fetch TradeAlpha news. If token is missing or expired, this tool returns auth_required=true and the agent must ask the user for credentials, call tradealpha_login, then retry tradealpha_news.",
),
);
api.registerTool(
createTradeAlphaNewsTool(
api,
"tradealpha_realtime_news",
"TradeAlpha Realtime News",
"Fetch real-time TradeAlpha news after login. Use tradealpha_news as the preferred alias. If token is missing or expired, call tradealpha_login first.",
),
);
},
};
FILE:references/login-flow.md
# TradeAlpha Login Flow
TradeAlpha 采用单 token 模式。
1. 使用账号密码调用登录接口
2. 从响应中的 `user.api_token` 提取唯一 token
3. 将 token 存到本地配置,供后续新闻接口复用
4. 如果新闻接口返回认证失效,重新登录并重试
当前实现优先读取:
1. 环境变量 `TRADEALPHA_API_KEY`
2. 本地配置文件中的已保存 token
登录脚本:
- `scripts/login.js`
新闻脚本:
- `scripts/get-realtime-news.js`
FILE:references/news-api.md
# TradeAlpha News API Notes
TradeAlpha 实时新闻支持以下常用筛选参数:
- `start_time`
- `end_time`
- `source`
- `category`
- `level`
- `page`
- `page_size`
插件总入口 `tradealpha_open_platform` 额外提供两个便捷参数:
- `intent`: `login` 或 `news`
- `timeframe`: `today` 或 `latest`
推荐约定:
- 用户说“今天新闻”时,优先用 `timeframe: "today"`
- 用户未给时间范围时,走接口默认近 24 小时逻辑
- 认证失败时,不直接结束;先引导登录,再重试
FILE:scripts/get-realtime-news.js
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const REALTIME_NEWS_URL =
"https://quantaccess.lxaa.top/api/v1/news/realtime_news";
const CONFIG_PATH = path.join(
os.homedir(),
".config",
"tradealpha-open-platform",
"config.json",
);
const MIN_NEWS_TIME = "2025-04-01 00:00:00";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const NEWS_SOURCES = [
"domestic",
"truth",
"bloomberg",
"rtrs",
"research_report",
];
const NEWS_CATEGORIES = [
"政治军事",
"社会",
"娱乐体育",
"公司",
"超大型公司",
"政策",
"市场与货币",
];
const NEWS_LEVELS = ["很重要", "重要", "一般"];
function printHelp() {
console.log("TradeAlpha 实时新闻脚本");
console.log("");
console.log("用法:");
console.log(" node scripts/get-realtime-news.js");
console.log(
' node scripts/get-realtime-news.js \'{"source":"bloomberg","level":"重要","page_size":5}\'',
);
console.log("");
console.log("支持字段:");
console.log(" start_time, end_time, source, category, level, page, page_size");
}
function printJson(payload, error = false) {
const encoded = JSON.stringify(payload, null, 2);
if (error) {
console.error(encoded);
return;
}
console.log(encoded);
}
function readStoredToken() {
const envToken = process.env.TRADEALPHA_API_KEY?.trim();
if (envToken) {
return {
token: envToken,
token_source: "env",
};
}
if (!fs.existsSync(CONFIG_PATH)) {
return {
token: null,
token_source: "none",
};
}
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
const parsed = JSON.parse(raw);
const storedToken = typeof parsed?.apiToken === "string" ? parsed.apiToken.trim() : null;
return {
token: storedToken,
token_source: storedToken ? "local-config" : "none",
};
} catch {
return {
token: null,
token_source: "none",
};
}
}
function parseInput(argv) {
const firstArg = argv[0];
if (!firstArg) {
return {};
}
if (firstArg === "--help" || firstArg === "-h") {
printHelp();
process.exit(0);
}
try {
const parsed = JSON.parse(firstArg);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("输入必须是 JSON 对象。");
}
return parsed;
} catch (error) {
throw new Error(
`参数必须是单个 JSON 对象字符串,例如 '{"source":"bloomberg","page_size":5}'。原始错误:String(error)`,
);
}
}
function parseOptionalString(args, key) {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`参数 \`key\` 必须是字符串。`);
}
const trimmed = value.trim();
return trimmed || undefined;
}
function parseOptionalInteger(args, key) {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
throw new Error(`参数 \`key\` 必须是整数。`);
}
function validateEnum(value, key, allowedValues) {
if (!value) {
return undefined;
}
if (!allowedValues.includes(value)) {
throw new Error(
`参数 \`key\` 取值无效,可选值为:allowedValues.join("、")。`,
);
}
return value;
}
function normalizeComparableTime(value) {
return DATE_ONLY_PATTERN.test(value) ? `value 00:00:00` : value;
}
function validateTimeFormat(value, key) {
if (!DATE_ONLY_PATTERN.test(value) && !DATE_TIME_PATTERN.test(value)) {
throw new Error(
`参数 \`key\` 格式无效,必须为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss。`,
);
}
if (normalizeComparableTime(value) < MIN_NEWS_TIME) {
throw new Error(
`参数 \`key\` 不能早于 2025-04-01 00:00:00(北京时间)。`,
);
}
}
function buildRequest(rawArgs) {
const start_time = parseOptionalString(rawArgs, "start_time");
const end_time = parseOptionalString(rawArgs, "end_time");
const source = validateEnum(
parseOptionalString(rawArgs, "source"),
"source",
NEWS_SOURCES,
);
const category = validateEnum(
parseOptionalString(rawArgs, "category"),
"category",
NEWS_CATEGORIES,
);
const level = validateEnum(
parseOptionalString(rawArgs, "level"),
"level",
NEWS_LEVELS,
);
const page = parseOptionalInteger(rawArgs, "page") ?? 1;
const page_size = parseOptionalInteger(rawArgs, "page_size") ?? 20;
if (start_time) {
validateTimeFormat(start_time, "start_time");
}
if (end_time) {
validateTimeFormat(end_time, "end_time");
}
if (
start_time &&
end_time &&
normalizeComparableTime(start_time) > normalizeComparableTime(end_time)
) {
throw new Error("`start_time` 不能晚于 `end_time`。");
}
if (page < 1) {
throw new Error("参数 `page` 必须大于或等于 1。");
}
if (page_size < 1 || page_size > 100) {
throw new Error("参数 `page_size` 必须在 1 到 100 之间。");
}
return {
start_time,
end_time,
source,
category,
level,
page,
page_size,
};
}
async function fetchRealtimeNews(request, token) {
const response = await fetch(REALTIME_NEWS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer token`,
},
body: JSON.stringify({
...request,
token,
}),
});
let payload;
try {
payload = await response.json();
} catch {
throw new Error(`新闻接口返回了非 JSON 响应,HTTP response.status。`);
}
if (!response.ok || payload?.code !== 0 || !payload?.data) {
const detail = typeof payload?.message === "string" ? payload.message : `HTTP response.status`;
const codeText =
typeof payload?.code === "number" ? `(code: payload.code)` : "";
const error = new Error(`获取新闻失败codeText:detail`);
error.tradealpha_code = typeof payload?.code === "number" ? payload.code : null;
throw error;
}
return payload.data;
}
async function main() {
const args = parseInput(process.argv.slice(2));
const request = buildRequest(args);
const auth = readStoredToken();
if (!auth.token) {
printJson(
{
success: false,
auth_required: true,
next_action: "provide_credentials",
token_source: auth.token_source,
error: `未找到 TradeAlpha token。请先运行 \`node scripts/login.js\` 或设置 \`TRADEALPHA_API_KEY\`。配置路径:CONFIG_PATH`,
message: "当前还没有可用 token,请先登录。",
},
true,
);
process.exitCode = 1;
return;
}
const data = await fetchRealtimeNews(request, auth.token);
printJson({
success: true,
auth_required: false,
next_action: "none",
token_source: auth.token_source,
request,
total: data.total,
page: data.page,
page_size: data.page_size,
list: data.list,
note: "新闻数据通常存在 0-5 分钟客观延迟。",
});
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
const tradealphaCode =
typeof error?.tradealpha_code === "number" ? error.tradealpha_code : null;
const authRequired = tradealphaCode === 1001;
printJson(
{
success: false,
auth_required: authRequired,
next_action: authRequired ? "provide_credentials" : "fetch_news",
token_source: "none",
error: message,
error_code: tradealphaCode,
message: authRequired
? "token 无效或已过期,请重新登录。"
: "新闻拉取失败,请检查参数或稍后重试。",
},
true,
);
process.exitCode = 1;
});
FILE:scripts/login.js
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const readline = require("node:readline/promises");
const { stdin, stdout } = require("node:process");
const LOGIN_URL = "https://quantaccess.lxaa.top/api/v1/auth/login/password";
const CONFIG_DIR = path.join(os.homedir(), ".config", "tradealpha-open-platform");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
function parseArgs(argv) {
const options = {
account: null,
password: null,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--account") {
options.account = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === "--password") {
options.password = argv[index + 1] ?? null;
index += 1;
continue;
}
if (arg === "--json") {
options.json = true;
continue;
}
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
}
return options;
}
function printHelp() {
console.log("TradeAlpha 登录脚本");
console.log("");
console.log("用法:");
console.log(" node scripts/login.js");
console.log(" node scripts/login.js --account 15600000000 --password 123456");
console.log(" node scripts/login.js --json");
}
function printJson(payload, error = false) {
const encoded = JSON.stringify(payload, null, 2);
if (error) {
console.error(encoded);
return;
}
console.log(encoded);
}
async function promptCredentials() {
const rl = readline.createInterface({
input: stdin,
output: stdout,
});
try {
const account = (await rl.question("TradeAlpha 账号: ")).trim();
rl.close();
const password = (await questionHidden("TradeAlpha 密码: ")).trim();
if (!account || !password) {
throw new Error("账号和密码都不能为空。");
}
return { account, password };
} finally {
rl.close();
}
}
function questionHidden(prompt) {
return new Promise((resolve, reject) => {
if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
const fallback = readline.createInterface({
input: stdin,
output: stdout,
});
fallback.question(prompt).then(resolve).catch(reject).finally(() => {
fallback.close();
});
return;
}
const wasRaw = stdin.isRaw;
const chars = [];
const cleanup = () => {
stdin.removeListener("data", onData);
stdin.setRawMode(Boolean(wasRaw));
stdout.write("\n");
};
const onData = (buffer) => {
const text = buffer.toString("utf8");
if (text === "\r" || text === "\n") {
cleanup();
resolve(chars.join(""));
return;
}
if (text === "\u0003") {
cleanup();
reject(new Error("登录已取消。"));
return;
}
if (text === "\u007f") {
chars.pop();
return;
}
chars.push(text);
};
stdout.write(prompt);
stdin.setRawMode(true);
stdin.resume();
stdin.on("data", onData);
});
}
async function login(account, password) {
const response = await fetch(LOGIN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ account, password }),
});
let payload;
try {
payload = await response.json();
} catch {
throw new Error(`登录失败,接口返回了非 JSON 响应,HTTP response.status`);
}
if (!response.ok) {
const detail =
typeof payload?.detail === "string"
? payload.detail
: typeof payload?.message === "string"
? payload.message
: `HTTP response.status`;
throw new Error(`登录失败:detail`);
}
const apiToken = payload?.user?.api_token;
if (typeof apiToken !== "string" || apiToken.length === 0) {
throw new Error("登录成功,但响应里没有 user.api_token。");
}
return {
success: true,
auth_required: false,
next_action: "fetch_news",
token_source: "local-config",
apiToken,
accessToken:
typeof payload?.access_token === "string" ? payload.access_token : null,
tokenType: typeof payload?.token_type === "string" ? payload.token_type : null,
user: payload?.user ?? null,
};
}
async function saveConfig(config) {
await fs.mkdir(CONFIG_DIR, { recursive: true });
await fs.writeFile(CONFIG_PATH, `JSON.stringify(config, null, 2)\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(CONFIG_PATH, 0o600);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
if (!options.json) {
console.log("TradeAlpha 登录");
console.log(`登录地址: LOGIN_URL`);
}
const account = typeof options.account === "string" ? options.account.trim() : "";
const password =
typeof options.password === "string" ? options.password.trim() : "";
const credentials =
account && password ? { account, password } : await promptCredentials();
const result = await login(credentials.account, credentials.password);
await saveConfig({
apiToken: result.apiToken,
accessToken: result.accessToken,
tokenType: result.tokenType,
account: credentials.account,
user: result.user,
savedAt: new Date().toISOString(),
});
if (options.json) {
printJson({
success: true,
auth_required: false,
next_action: "fetch_news",
token_source: "local-config",
account: credentials.account,
configPath: CONFIG_PATH,
user: result.user,
message: "登录成功,已保存唯一 token。",
});
return;
}
console.log("");
console.log("登录成功,已保存唯一 token。");
console.log(`配置文件: CONFIG_PATH`);
console.log("后续 Skill 可直接读取本地配置,无需重复输入。");
}
main().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
printJson(
{
success: false,
auth_required: true,
next_action: "provide_credentials",
token_source: "none",
error: message,
message: "登录失败,请检查账号密码后重试。",
},
true,
);
process.exitCode = 1;
});
FILE:skills/tradealpha-open-platform/SKILL.md
---
name: tradealpha-open-platform
description: Bundled TradeAlpha router skill for OpenClaw. Use for today's news, market news, Reuters, Bloomberg, Truth Social, Chinese mainstream headlines, or when the user needs to log in or refresh a TradeAlpha token. Prefer the single tool `tradealpha_open_platform`.
homepage: https://quantaccess.lxaa.top
version: 0.4.0
---
# TradeAlpha开放平台
这是插件内置 skill,用来把自然语言请求稳定路由到 `tradealpha_open_platform`。
## First Rule
只要用户要新闻、要登录、要 token、要刷新 token,就优先调用 `tradealpha_open_platform`。
## Routing Rules
### 登录
如果用户想登录、初始化 token、刷新 token:
1. 先索要 `account` 和 `password`
2. 调用 `tradealpha_open_platform`,传:
- `intent: "login"`
- `account`
- `password`
### 新闻
如果用户想获取今天新闻、市场新闻、路透、彭博、Truth 或国内主流消息:
1. 先调用 `tradealpha_open_platform`
2. 对“今天新闻”优先传 `timeframe: "today"`
3. 如果返回 `auth_required: true`:
- 先索要 `account` 和 `password`
- 再次调用 `tradealpha_open_platform`,补上 `account` 和 `password`
## Common Parameters
- `intent`
- `timeframe`
- `start_time`
- `end_time`
- `source`
- `category`
- `level`
- `page`
- `page_size`
## Runtime Rules
- 不要把 `tradealpha-open-platform` 当成 tool 名;真正的 tool 名是 `tradealpha_open_platform`
- 只有当总入口不适合时,才回退到 `tradealpha_login` / `tradealpha_news`
- 不要在回复中回显用户密码或 token
FILE:src/auth.ts
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const CONFIG_PATH = path.join(
os.homedir(),
".config",
"tradealpha-open-platform",
"config.json",
);
export interface StoredTradeAlphaConfig {
apiToken?: string;
accessToken?: string | null;
tokenType?: string | null;
account?: string;
user?: unknown;
savedAt?: string;
}
export function getTradeAlphaConfigPath(): string {
return CONFIG_PATH;
}
export function readStoredTradeAlphaConfig(): StoredTradeAlphaConfig | null {
if (!fs.existsSync(CONFIG_PATH)) {
return null;
}
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
return JSON.parse(raw) as StoredTradeAlphaConfig;
} catch {
return null;
}
}
export function getTradeAlphaApiToken(): string | null {
const envToken = process.env.TRADEALPHA_API_KEY?.trim();
if (envToken) {
return envToken;
}
const storedConfig = readStoredTradeAlphaConfig();
const storedToken = storedConfig?.apiToken?.trim();
return storedToken || null;
}
FILE:src/index.ts
import { fetchRealtimeNews, realtimeNewsEnums } from "./news";
/**
* TradeAlpha Open Platform - OpenClaw Skill
*
* Aggregates major global and Chinese news sources for market intelligence.
*/
export interface ToolResult {
success: boolean;
data?: unknown;
error?: string;
}
export interface SkillTool {
name: string;
description: string;
execute: (args: Record<string, unknown>) => Promise<ToolResult>;
}
const SKILL_NAME = "TradeAlpha开放平台";
const SKILL_DESCRIPTION_ZH =
"路透、彭博、川普Truth、国内主流消息源,一网打尽";
const SKILL_DESCRIPTION_EN =
"Reuters, Bloomberg, Trump's Truth Social, and major Chinese news sources, all in one place.";
const getRealtimeNewsTool: SkillTool = {
name: "get-realtime-news",
description:
"Fetches real-time news from Reuters, Bloomberg, Truth Social, research reports, and Chinese mainstream sources.",
execute: async (args: Record<string, unknown>): Promise<ToolResult> => {
const result = await fetchRealtimeNews(args);
if (!result.success) {
return result;
}
return {
success: true,
data: {
skill: SKILL_NAME,
...((result.data as Record<string, unknown>) ?? {}),
filters: {
sources: realtimeNewsEnums.sources,
categories: realtimeNewsEnums.categories,
levels: realtimeNewsEnums.levels,
minNewsTime: realtimeNewsEnums.minNewsTime,
},
},
};
},
};
export const tools: SkillTool[] = [getRealtimeNewsTool];
export default {
metadata: {
name: "tradealpha-open-platform",
title: SKILL_NAME,
description: `SKILL_NAME:SKILL_DESCRIPTION_ZH SKILL_DESCRIPTION_EN`,
descriptionZh: `SKILL_NAME:SKILL_DESCRIPTION_ZH`,
descriptionEn: `TradeAlpha Open Platform: SKILL_DESCRIPTION_EN`,
version: "0.4.0",
},
tools,
};
FILE:src/news.ts
import { getTradeAlphaApiToken, getTradeAlphaConfigPath } from "./auth";
import type { ToolResult } from "./index";
export const REALTIME_NEWS_URL =
"https://quantaccess.lxaa.top/api/v1/news/realtime_news";
const MIN_NEWS_TIME = "2025-04-01 00:00:00";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const NEWS_SOURCES = [
"domestic",
"truth",
"bloomberg",
"rtrs",
"research_report",
] as const;
const NEWS_CATEGORIES = [
"政治军事",
"社会",
"娱乐体育",
"公司",
"超大型公司",
"政策",
"市场与货币",
] as const;
const NEWS_LEVELS = ["很重要", "重要", "一般"] as const;
type NewsSource = (typeof NEWS_SOURCES)[number];
type NewsCategory = (typeof NEWS_CATEGORIES)[number];
type NewsLevel = (typeof NEWS_LEVELS)[number];
export interface RealtimeNewsRequest {
start_time?: string;
end_time?: string;
source?: NewsSource;
category?: NewsCategory;
level?: NewsLevel;
page?: number;
page_size?: number;
}
interface RealtimeNewsItem {
id: number;
datetime: string;
content: string;
source: string;
category: string;
level: string;
}
interface RealtimeNewsApiResponse {
code: number;
message: string;
data?: {
total: number;
page: number;
page_size: number;
list: RealtimeNewsItem[];
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseOptionalString(
args: Record<string, unknown>,
key: string,
): string | undefined {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`参数 \`key\` 必须是字符串。`);
}
const trimmed = value.trim();
return trimmed || undefined;
}
function parseOptionalInteger(
args: Record<string, unknown>,
key: string,
): number | undefined {
const value = args[key];
if (value == null) {
return undefined;
}
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
throw new Error(`参数 \`key\` 必须是整数。`);
}
function validateEnum<T extends readonly string[]>(
value: string | undefined,
key: string,
allowedValues: T,
): T[number] | undefined {
if (!value) {
return undefined;
}
if (!allowedValues.includes(value as T[number])) {
throw new Error(
`参数 \`key\` 取值无效,可选值为:allowedValues.join("、")。`,
);
}
return value as T[number];
}
function normalizeComparableTime(value: string): string {
return DATE_ONLY_PATTERN.test(value) ? `value 00:00:00` : value;
}
function validateTimeFormat(value: string, key: string): void {
if (!DATE_ONLY_PATTERN.test(value) && !DATE_TIME_PATTERN.test(value)) {
throw new Error(
`参数 \`key\` 格式无效,必须为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss。`,
);
}
const comparable = normalizeComparableTime(value);
if (comparable < MIN_NEWS_TIME) {
throw new Error(
`参数 \`key\` 不能早于 2025-04-01 00:00:00(北京时间)。`,
);
}
}
function validateTimeRange(
startTime: string | undefined,
endTime: string | undefined,
): void {
if (!startTime || !endTime) {
return;
}
if (normalizeComparableTime(startTime) > normalizeComparableTime(endTime)) {
throw new Error("`start_time` 不能晚于 `end_time`。");
}
}
export function buildRealtimeNewsRequest(
rawArgs: Record<string, unknown>,
): RealtimeNewsRequest {
const start_time = parseOptionalString(rawArgs, "start_time");
const end_time = parseOptionalString(rawArgs, "end_time");
const source = validateEnum(
parseOptionalString(rawArgs, "source"),
"source",
NEWS_SOURCES,
);
const category = validateEnum(
parseOptionalString(rawArgs, "category"),
"category",
NEWS_CATEGORIES,
);
const level = validateEnum(
parseOptionalString(rawArgs, "level"),
"level",
NEWS_LEVELS,
);
const page = parseOptionalInteger(rawArgs, "page") ?? 1;
const page_size = parseOptionalInteger(rawArgs, "page_size") ?? 20;
if (start_time) {
validateTimeFormat(start_time, "start_time");
}
if (end_time) {
validateTimeFormat(end_time, "end_time");
}
validateTimeRange(start_time, end_time);
if (page < 1) {
throw new Error("参数 `page` 必须大于或等于 1。");
}
if (page_size < 1 || page_size > 100) {
throw new Error("参数 `page_size` 必须在 1 到 100 之间。");
}
return {
start_time,
end_time,
source,
category,
level,
page,
page_size,
};
}
function getTokenOrThrow(): string {
const token = getTradeAlphaApiToken();
if (!token) {
throw new Error(
`未找到 TradeAlpha token。请先运行 \`npm run login\`,或设置 \`TRADEALPHA_API_KEY\`。本地配置路径:getTradeAlphaConfigPath()`,
);
}
return token;
}
export async function fetchRealtimeNews(
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
try {
const request = buildRealtimeNewsRequest(rawArgs);
const token = getTokenOrThrow();
const response = await fetch(REALTIME_NEWS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer token`,
},
body: JSON.stringify({
...request,
token,
}),
});
let payload: unknown;
try {
payload = await response.json();
} catch {
return {
success: false,
error: `新闻接口返回了非 JSON 响应,HTTP response.status。`,
};
}
if (!isRecord(payload)) {
return {
success: false,
error: "新闻接口返回了无法识别的响应结构。",
};
}
const apiResponse = payload as unknown as RealtimeNewsApiResponse;
if (!response.ok || apiResponse.code !== 0 || !apiResponse.data) {
const detail = apiResponse.message || `HTTP response.status`;
return {
success: false,
error:
typeof apiResponse.code === "number"
? `获取新闻失败(code: apiResponse.code):detail`
: `获取新闻失败:detail`,
};
}
return {
success: true,
data: {
request,
total: apiResponse.data.total,
page: apiResponse.data.page,
pageSize: apiResponse.data.page_size,
items: apiResponse.data.list,
note: "新闻数据通常存在 0-5 分钟客观延迟。",
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
export const realtimeNewsEnums = {
sources: NEWS_SOURCES,
categories: NEWS_CATEGORIES,
levels: NEWS_LEVELS,
minNewsTime: MIN_NEWS_TIME,
};
FILE:tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
中文论文复现执行工作流。用于用户上传或提供深度学习、机器学习、LLM、CV、NLP、多模态、数据集、benchmark、prompt 工程或 agent 论文的 PDF、arXiv 链接、论文主页、项目页、标题摘要或源码线索,并要求判断可复现性、搜索官方代码、检查本地源码、追踪数据集论文源码、定位数据处理代码、自...
---
name: paper-repro-triage
description: 中文论文复现执行工作流。用于用户上传或提供深度学习、机器学习、LLM、CV、NLP、多模态、数据集、benchmark、prompt 工程或 agent 论文的 PDF、arXiv 链接、论文主页、项目页、标题摘要或源码线索,并要求判断可复现性、搜索官方代码、检查本地源码、追踪数据集论文源码、定位数据处理代码、自动 clone 仓库,或在无线上/本地源码但具备复现条件时生成符合常见 PyTorch 开源项目直觉的复现工程。最终写入 Markdown 报告,聊天只返回极简中文摘要。
---
# 论文复现初筛、源码溯源与复现工程生成
## 总原则
本技能用于把论文分析从“聊天式建议”升级为“面向复现的执行工作流”。回答必须使用中文。除非工具权限、网络、审批或用户环境阻止,否则不要只告诉用户去执行命令;应优先使用可用工具完成可执行动作。
每次触发后,聊天回复第一行必须输出:
```text
[paper-repro-triage active]
```
详细结果必须写入 Markdown 文件,聊天只返回极简摘要。
## 强制行为
1. **详细结果写入 Markdown**:默认写到当前 agent workspace 下的 `paper-repro-workspace/<paper-slug>/repro-report.md`。
2. **聊天内容极简**:只输出报告路径、主论文源码状态、数据集源码状态、复现工程状态、是否需要复现、是否能复现、核心原因。
3. **不要输出“下一步建议”作为流程终点**:如果当前流程能继续执行,就继续执行;聊天摘要和报告末尾只写“未完成项/人工确认项”。
4. **先找源码,再谈复现**:必须按“线上官方代码 → 本地主论文源码 → 数据集论文源码 → 无主论文源码复现工程”的顺序执行。不能因为 GitHub 没搜到就立即从零复现。
5. **数据集源码或 baseline 源码不能替代主论文源码**:如果只找到数据集相关源码、baseline 代码、第三方实现或旧方法代码,必须继续判断是否要生成主论文复现工程。
6. **遇到代码仓库优先自动 clone**:主论文官方仓库、数据集论文官方仓库、项目页仓库都应优先 clone 到 workspace;但 clone 前必须先查本地是否已有相关源码。
7. **重复目录跳过 clone**:如果 clone 目标路径下已有同名源码文件夹,不要再次 clone,不要自动 `git pull`,不要覆盖,不要改用时间戳目录;应读取现有目录做只读检查,并在报告与聊天摘要中写明 `已存在,跳过 clone`。
8. **遇到数据集必须做源码溯源**:不用下载数据集论文 PDF,也不用下载数据集本体;只搜索数据集原论文、项目页、arXiv 摘要页、Papers with Code、GitHub/GitLab/Hugging Face 线索,判断是否有官方源码、处理脚本或 benchmark 代码。
9. **必须定位数据处理代码**:对主论文源码、baseline 源码和数据集相关源码,都要定位数据加载、预处理、划分、特征抽取、标注解析、benchmark 构建等代码位置,并写入报告。
10. **无主论文源码时必须尝试生成复现工程**:当线上没有官方源码、本地没有主论文源码,且论文证据支持“可以直接复现”或“部分可复现”时,必须生成 PyTorch 复现工程;不能只写方案。
11. **生成工程要符合常见开源直觉**:默认采用“根目录入口 + 四个代码目录 + 一个复现文档目录”的简洁 PyTorch 结构:根目录保留 `main.py`、`config.py`、`run.py`;代码放入 `data/`、`models/`、`engine/`、`utils/`;`requirements.txt`、`paper-spec.yaml`、`evidence-map.md`、`repro-notes.md` 统一放入 `repro-docs/`。该结构是最低基本盘,可按论文需要扩展,但不要默认生成 `configs/` 多 YAML 目录、独立 `losses/` 目录、`scripts/` 训练脚本或 `.sh` 文件。
12. **不要伪造复现结果**:可以生成代码和 smoke check,但不能声称已经复现论文结果。论文未给出的超参数、模块或处理步骤必须标注为 `ASSUMPTION` 或 `TODO`。
13. **主论文源码存在时必须停在代码导读阶段**:如果已找到、已 clone、已跳过 clone 或本地已存在主论文官方/高度可信源码,本次技能流程的终点是“仓库导读 + 数据处理代码定位 + 写入报告 + 极简摘要”。不得继续修复源码、配置数据目录、安装依赖、下载数据、运行训练、运行评测或执行 inference。
14. **“复现”默认表示复现分析与准备**:用户只说“复现这篇论文”“重新跑一遍”“处理这篇论文”时,不代表允许训练;只有用户明确说“运行训练/开始训练/跑通训练/执行评测/下载数据/修复代码并运行”,才进入运行类任务。
15. **运行类任务不属于本技能自动阶段**:即使 exec 权限是 full/ask=off,本技能也不能自动安装依赖、下载数据、改官方代码或跑训练。
## 输入
接受以下输入:论文 PDF、arXiv 链接、论文主页链接、项目页链接、论文标题/摘要/正文片段、GitHub/GitLab 链接,以及“判断是否值得复现”“找代码”“自动 clone”“读仓库”“整理实验配置”“查数据集论文源码”“生成复现工程”“写 md 报告”等请求。
## 必须优先使用的工具
根据当前 OpenClaw 环境中可用的工具执行:
1. 使用 PDF 工具或文件读取能力抽取论文正文、附录、脚注、表格、图注和参考文献。
2. 使用 web/search/fetch 类工具读取 arXiv 页面、项目页、论文中出现的外部链接、数据集原论文页面和公开代码页面。
3. 使用 exec/shell 工具执行仓库和文件相关命令,例如 `git clone`、`python scripts/bootstrap_repo.py`、`python scripts/find_local_code.py`、`python scripts/inspect_repo_data_processing.py`、`python scripts/build_paper_spec.py`、`python scripts/scaffold_repro_project.py`、`python scripts/inspect_repro_project.py`、`dir`、`find`、写入 `.md` 文件。
4. Windows cmd 环境优先使用 Python 脚本:`python ...`;如果 `python` 不可用,尝试 `py ...`。
5. 不使用 `.sh` 作为默认路径;本技能不生成 `.sh` 训练脚本。
6. 如果 exec 不可用、被拒绝、网络失败或 Python 不可用,必须在报告中说明失败原因和退化路径。
## 工作区约定
1. 优先在当前 agent workspace 下创建 `paper-repro-workspace/`。
2. 对每篇主论文创建安全目录名:`paper-repro-workspace/<paper-slug>/`。
3. 详细报告:`paper-repro-workspace/<paper-slug>/repro-report.md`。
4. 主论文代码:`paper-repro-workspace/<paper-slug>/main-code/<repo-name>/`。
5. 数据集论文或数据集项目代码:`paper-repro-workspace/<paper-slug>/dataset-code/<dataset-slug>/<repo-name>/`。
6. 本地手动放置源码可位于:`paper-repro-workspace/<paper-slug>/local-code/`。
7. 无代码生成工程目录不得固定为 `repro-implementation`。必须根据论文框架、方法、模型或任务名生成:`paper-repro-workspace/<paper-slug>/<framework-or-method-slug>-reproduction/`。如果只能做 baseline,目录名必须包含 `baseline`。
8. 不要在用户系统随机目录中 clone 或生成代码,不要覆盖已有目录。
## 执行边界与停止条件
- **主论文源码存在即停止在代码导读阶段**:主论文官方/高度可信源码已 clone、已存在或本地已找到时,只做仓库导读、入口定位、数据处理代码定位、写报告和极简摘要。
- **禁止自动运行阶段**:主论文源码存在时,不安装依赖、不下载数据、不修复源码路径、不设置真实数据目录、不运行训练/评估/推理、不生成新的 `<method-slug>-reproduction/` 工程。
- **无主论文源码才生成复现工程**:只有线上和本地都没有主论文源码,并且论文可直接复现或部分可复现时,才生成 `<method-slug>-reproduction/`。
- **数据集源码和 baseline 源码不能替代主论文源码**:它们只能作为数据处理或实现参考证据;如果主论文没有源码,仍需判断并生成主论文复现工程。
- **后续短句不自动跑训练**:报告产出后,用户只说“复现/继续/重新跑一遍”时,默认重新执行本技能流程,不得擅自开始训练;明确要求训练时才视为新的运行任务。
## 总体流程
### 第 1 步:读取论文证据
从论文 PDF、arXiv 页面或用户提供文本中提取:标题、作者、年份、会议或期刊、摘要、核心贡献、方法、实验、附录、脚注、代码可用性声明、数据集、指标、baseline、训练细节、图表标题和图注、明确的 GitHub/GitLab/项目页/Hugging Face/数据集链接。
如果无法读取 PDF 或附件,先说明缺失的工具或输入,不要编造论文内容。
### 第 2 步:论文类型分类
必须给出一个主类型,必要时给出次类型。可选类型:综述论文、方法论文、提示词工程论文、基准评测论文、资源论文、理论论文、系统论文。
### 第 3 步:可复现性判定
使用 `references/reproducibility-rubric.md`。只能输出以下四个标签之一:可以直接复现、部分可复现、不具备实际可复现性、不是复现目标。
必须区分“能不能复现”和“需不需要复现”。不要把“有论文描述”误判成“可以直接复现”。
### 第 4 步:主论文代码线索搜索
必须主动搜索论文证据中的代码线索:PDF URL、脚注、附录、作者说明、arXiv abstract 页面、project page、supplementary material、OpenReview 页面、`code is available`、`source code`、`implementation`、`official repository`、`github`、`project page` 等。
如果发现多个仓库,优先判断作者官方仓库。无法确认时,标记为“官方性未验证”。
### 第 5 步:本地主论文源码检查
在进入无代码复现前,必须检查本地是否已有主论文相关源码。优先使用:
```text
python scripts/find_local_code.py --paper-slug <paper-slug> --name <paper-title-or-method> --workspace .
```
检查范围包括:`paper-repro-workspace/<paper-slug>/main-code/`、`paper-repro-workspace/<paper-slug>/local-code/`、当前 agent workspace、环境变量 `PAPER_REPRO_LOCAL_CODE_ROOTS`。数据集代码目录可以作为辅助证据,但不能直接判定为主论文源码。
如果本地找到高可信主论文源码,不进入无代码复现路径,而是进入“本地代码路径”:读取 README、依赖、训练入口、评测入口、配置、模型、数据处理代码,并写入报告。
### 第 6 步:数据集论文与数据集源码溯源
当主论文使用或发布数据集、benchmark 或标注资源时,必须执行此步骤。详细流程见 `references/dataset-source-tracing.md`。
对每个关键数据集,必须:
1. 提取数据集名称、简称、引用编号、数据集论文标题、项目页、数据下载页和脚注。
2. 检索数据集原论文、项目页、Papers with Code、GitHub/GitLab/Hugging Face 线索。
3. clone 前先检查本地是否已有相关源码。
4. 找到官方或可能官方源码后 clone 或跳过 clone。
5. 使用 `scripts/inspect_repo_data_processing.py` 或等价只读检查定位数据处理代码。
6. 报告数据处理代码文件、入口命令、关键函数/类、README 证据和对主论文复现的影响。
### 第 7 步:有主论文代码时自动执行并导读
如果发现主论文官方/高度可信代码,必须:
1. 记录“检测到主论文代码仓库,进入自动仓库路径”。
2. clone 前判断目标路径是否已有同名源码文件夹;若已有,跳过 clone,只读检查。
3. Windows 优先执行:`python scripts/bootstrap_repo.py <repo-url> <paper-slug> main-code`;如 `python` 不可用,尝试 `py scripts/bootstrap_repo.py ...`。
4. clone 成功或发现现有目录后,继续做仓库导读,不能停在“已经 clone”或“已存在”。
5. 使用 `scripts/inspect_repo_data_processing.py <repo-path>` 定位数据处理代码。
6. 报告本地路径、clone 状态、重复目录提醒、依赖文件、安装命令候选、训练/推理/评测入口、数据集准备方式、配置文件、模型文件、训练文件、评测文件、数据处理文件。
7. 完成第 6 项后必须写入报告并结束本技能流程;不得继续安装依赖、修复源码、设置真实数据路径、下载数据、运行训练、运行评测或执行 inference。
8. “可以直接复现”只表示具备复现条件,不表示现在开始执行训练。
### 第 8 步:无主论文源码时生成复现工程
只要满足以下条件,就必须生成复现工程,而不是只给建议:
- 线上没有官方/可信主论文源码;
- 本地没有主论文源码;
- 论文不是综述、纯理论或非复现目标;
- 论文证据支持“可以直接复现”或“部分可复现”;
- 数据集、模型结构、训练循环、loss、指标至少能构造最小可行版本。
如果找到数据集相关源码或 baseline 源码,要将其作为数据处理和 baseline 证据输入复现工程,但不能终止主论文复现工程生成。
详细规则见 `references/no-code-reproduction.md`。
生成前必须先写 `paper-spec.yaml`。可以使用:
```text
python scripts/build_paper_spec.py <evidence-md> --out paper-repro-workspace/<paper-slug>/paper-spec.yaml
```
然后生成工程:
```text
python scripts/scaffold_repro_project.py paper-repro-workspace/<paper-slug>/paper-spec.yaml --out paper-repro-workspace/<paper-slug>/<framework-or-method-slug>-reproduction
```
生成后运行静态检查:
```text
python scripts/inspect_repro_project.py paper-repro-workspace/<paper-slug>/<framework-or-method-slug>-reproduction
```
不自动安装依赖,不下载大数据,不运行训练。轻量 `py_compile` 和文件完整性检查可以自动执行。
### 第 9 步:写入 Markdown 报告
最终必须把详细内容写入:`paper-repro-workspace/<paper-slug>/repro-report.md`。
报告模板见 `references/output-template.md`。必须记录:论文信息、分类、可复现性、代码搜索、主论文源码、本地源码、数据集源码、数据处理代码位置、复现工程生成结果、执行过的命令、不能复现原因、未完成项/人工确认项。
## 聊天输出格式
聊天中不要输出长报告。聊天回复只输出:
```markdown
[paper-repro-triage active]
- 报告文件:`paper-repro-workspace/<paper-slug>/repro-report.md`
- 主论文源码:已 clone / 已存在,跳过 clone / 本地已存在 / 未找到 / 等待审批 / clone 失败
- 数据集源码:已 clone N 个 / 已存在,跳过 clone N 个 / 本地已存在 N 个 / 未找到 / 部分找到 / 未检索
- 数据处理代码:已定位 N 处 / 未定位 / 不适用
- 复现工程:已生成 / 仅生成 skeleton / 未生成,路径:`paper-repro-workspace/<paper-slug>/<implementation-slug>/`
- 是否需要复现:需要 / 不需要 / 建议只做部分复现
- 是否能复现:可以直接复现 / 部分可复现 / 不具备实际可复现性 / 不是复现目标
- 核心原因:一句话说明;如果能复现则写“无核心阻碍”
- 执行边界:未运行训练 / 未安装依赖 / 未下载数据;如已存在主论文源码,写“已停在代码导读阶段”
```
## 安全与诚实规则
- 不要伪造已经执行过的命令。
- 不要伪造仓库文件名。
- 不要伪造 Markdown 文件已经写入。
- 不要声称精确复现,除非代码、数据、配置和评测协议都足够充分。
- 不要把第三方复现仓库当成官方仓库。
- 不要自动安装未知依赖、下载大数据、修复官方源码路径、设置真实数据目录或运行训练/评测/推理脚本;clone、跳过重复 clone、只读仓库检查、生成复现工程、静态检查可以自动执行。
- 所有论文未明确给出的超参数、路径、模型维度、loss 权重、数据处理细节必须标注 `ASSUMPTION`。
- 如果只能生成 baseline,必须命名为 baseline,不能命名为 paper reproduction。
- 如果生成的代码含 `TODO` 或 `NotImplementedError`,报告必须列出。
## 资源
- 可复现性判定标准:`references/reproducibility-rubric.md`
- Markdown 报告模板:`references/output-template.md`
- 数据集论文源码溯源流程:`references/dataset-source-tracing.md`
- 无代码复现工程流程:`references/no-code-reproduction.md`
- 仓库 bootstrap 脚本:`scripts/bootstrap_repo.py`
- 本地源码查找:`scripts/find_local_code.py`
- 数据处理代码定位:`scripts/inspect_repo_data_processing.py`
- paper spec 草稿:`scripts/build_paper_spec.py`
- 复现工程生成:`scripts/scaffold_repro_project.py`
- 复现工程检查:`scripts/inspect_repro_project.py`
FILE:scripts/bootstrap_repo.py
#!/usr/bin/env python3
"""Clone or inspect a GitHub/GitLab repository for paper reproduction workspaces.
Usage:
python scripts/bootstrap_repo.py <repo-url> [paper-slug] [bucket]
The script is intentionally read-only after clone: it does not install dependencies,
download data, run training, or perform git pull on existing directories.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Iterable
ALLOWED_PREFIXES = (
"https://github.com/",
"[email protected]:",
"https://gitlab.com/",
"[email protected]:",
)
DEPENDENCY_PATTERNS = (
"requirements", "environment", "pyproject.toml", "setup.py", "setup.cfg",
"Pipfile", "Dockerfile", "conda", "poetry.lock", "package.json",
)
ENTRY_RE = re.compile(r"^(train|main|run|eval|test|infer|demo).*\.(py|ipynb|sh|cmd|ps1)$", re.I)
def safe_component(value: str, default: str = "paper") -> str:
value = value.lower().replace("\\", "/")
value = re.sub(r"[^a-z0-9._/-]+", "-", value)
value = re.sub(r"/{2,}", "/", value).strip("/")
value = re.sub(r"(^|/)-+", r"\1", value)
value = re.sub(r"-+(/|$)", r"\1", value)
return value or default
def repo_name_from_url(url: str) -> str:
name = url.rstrip("/").split("/")[-1]
if ":" in name and url.startswith("git@"):
name = name.split(":")[-1]
if name.endswith(".git"):
name = name[:-4]
return re.sub(r"[^A-Za-z0-9._-]+", "-", name) or "repo"
def run(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]:
try:
proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300)
return proc.returncode, proc.stdout
except FileNotFoundError as exc:
return 127, str(exc)
except subprocess.TimeoutExpired as exc:
return 124, (exc.stdout or "") + "\n[timeout] command timed out"
def rel_files(root: Path, max_depth: int = 2) -> list[str]:
out: list[str] = []
if not root.exists():
return out
for path in root.rglob("*"):
try:
rel = path.relative_to(root)
except ValueError:
continue
if len(rel.parts) > max_depth:
continue
if any(part in {".git", "__pycache__", ".venv", "node_modules"} for part in rel.parts):
continue
out.append(str(rel).replace("\\", "/") + ("/" if path.is_dir() else ""))
return sorted(out)
def find_files(root: Path, max_depth: int, predicate) -> list[str]:
matches: list[str] = []
if not root.exists():
return matches
for path in root.rglob("*"):
if not path.is_file():
continue
try:
rel = path.relative_to(root)
except ValueError:
continue
if len(rel.parts) > max_depth:
continue
if any(part in {".git", "__pycache__", ".venv", "node_modules"} for part in rel.parts):
continue
if predicate(path):
matches.append(str(rel).replace("\\", "/"))
return sorted(matches)
def find_readme(root: Path) -> Path | None:
for name in ("README.md", "README.rst", "README.txt", "readme.md"):
candidate = root / name
if candidate.exists():
return candidate
for path in root.rglob("README*"):
try:
if len(path.relative_to(root).parts) <= 2 and path.is_file():
return path
except ValueError:
pass
return None
def read_head(path: Path, max_lines: int = 160) -> str:
try:
with path.open("r", encoding="utf-8", errors="replace") as f:
return "".join(line for _, line in zip(range(max_lines), f))
except OSError as exc:
return f"[read failed] {exc}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Clone or inspect repo in paper-repro-workspace")
parser.add_argument("repo_url")
parser.add_argument("paper_slug", nargs="?", default="paper")
parser.add_argument("bucket", nargs="?", default="main-code")
args = parser.parse_args(argv)
repo_url = args.repo_url.strip()
if not repo_url.startswith(ALLOWED_PREFIXES):
print(f"错误:仓库地址看起来不是 GitHub/GitLab URL:{repo_url}", file=sys.stderr)
return 2
safe_slug = safe_component(args.paper_slug, "paper")
safe_bucket = safe_component(args.bucket, "main-code")
root_dir = Path("paper-repro-workspace") / safe_slug / safe_bucket
root_dir.mkdir(parents=True, exist_ok=True)
repo_name = repo_name_from_url(repo_url)
target_dir = root_dir / repo_name
clone_status = "已 clone"
clone_note = "新克隆仓库。"
remote_url = ""
command_summary = ""
if target_dir.exists():
clone_status = "已存在,跳过 clone"
command_summary = "未执行 git clone,只做现有目录检查。"
if (target_dir / ".git").exists():
code, origin = run(["git", "-C", str(target_dir), "remote", "get-url", "origin"])
remote_url = origin.strip() if code == 0 else ""
if remote_url == repo_url:
clone_note = "目标目录已存在且 origin 与目标仓库一致;按规则不重复 clone,也不自动 git pull。"
elif remote_url:
clone_note = f"目标目录已存在且是 git 仓库,但 origin 与目标仓库不一致:{remote_url};按规则不覆盖、不重复 clone。"
else:
clone_note = "目标目录已存在且是 git 仓库,但没有读取到 origin;按规则不重复 clone。"
else:
clone_note = "目标目录已存在但不是 git 仓库;按规则不覆盖、不重复 clone。"
else:
command_summary = f"git clone {repo_url} {target_dir}"
code, output = run(["git", "clone", repo_url, str(target_dir)])
if code != 0:
print("=== 执行脚本 ===")
print("脚本:bootstrap_repo.py")
print(f"命令:{command_summary}")
print(output)
return code
print("=== 执行脚本 ===")
print("脚本:bootstrap_repo.py")
print(f"命令:{command_summary}")
print("")
print("=== 克隆结果 ===")
print(f"仓库状态:{clone_status}")
print(f"重复目录提醒:{clone_note}")
print(f"本地路径:{target_dir}")
print(f"远程地址:{repo_url}")
if remote_url:
print(f"现有 origin:{remote_url}")
print("\n=== 顶层结构 ===")
for item in rel_files(target_dir, 2)[:120]:
print(item)
print("\n=== 常见依赖文件 ===")
dep_files = find_files(target_dir, 3, lambda p: any(token.lower() in p.name.lower() for token in DEPENDENCY_PATTERNS))
for item in dep_files:
print(item)
print("\n=== 常见入口候选 ===")
entry_files = find_files(target_dir, 4, lambda p: bool(ENTRY_RE.match(p.name)))
for item in entry_files[:80]:
print(item)
print("\n=== README 摘要候选 ===")
readme = find_readme(target_dir)
if readme:
print(f"README 文件:{readme}")
print(read_head(readme, 160))
else:
print("未在前两层目录找到 README。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/build_paper_spec.py
#!/usr/bin/env python3
"""Build a paper-spec.yaml draft from extracted evidence text.
This script intentionally creates a conservative draft. The model should edit the
YAML using paper evidence before scaffolding implementation code.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def slugify(value: str, default: str = "paper") -> str:
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return value[:80] or default
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def infer_title(text: str) -> str:
for line in text.splitlines()[:80]:
clean = line.strip().strip("# ")
if 8 <= len(clean) <= 180 and not clean.lower().startswith(("abstract", "introduction", "arxiv")):
return clean
return "UNKNOWN"
def infer_method(title: str) -> str:
words = re.findall(r"[A-Za-z0-9]+", title)
if not words:
return "paper"
# Prefer acronym-like tokens or first two content words.
acronyms = [w for w in words if len(w) >= 3 and w.upper() == w]
if acronyms:
return acronyms[0]
return "-".join(words[:3])
def main() -> int:
parser = argparse.ArgumentParser(description="Create paper-spec.yaml draft")
parser.add_argument("evidence_md")
parser.add_argument("--out", required=True)
args = parser.parse_args()
evidence_path = Path(args.evidence_md)
text = read_text(evidence_path) if evidence_path.exists() else ""
title = infer_title(text)
method = infer_method(title)
paper_slug = slugify(title)
method_slug = slugify(method, paper_slug)
implementation_slug = f"{method_slug}-reproduction"
yaml = f"""# Generated by build_paper_spec.py. Edit with paper evidence before scaffolding.
paper:
title: "{title}"
year: "UNKNOWN"
venue: "UNKNOWN"
task: "TODO"
modality: "TODO"
method_name: "{method}"
paper_slug: "{paper_slug}"
implementation_slug: "{implementation_slug}"
architecture:
type: "TODO"
modules:
- "TODO"
inputs:
- "TODO"
outputs:
- "TODO"
loss_terms:
- "TODO"
datasets:
- name: "TODO"
role: "train/eval"
source_paper: "TODO"
access: "TODO"
preprocessing: "TODO"
local_source_code: "TODO"
data_processing_files:
- "TODO"
training:
seed: 42 # ASSUMPTION: debug default unless paper specifies.
optimizer: "TODO"
learning_rate: "TODO"
batch_size: "TODO"
epochs_or_steps: "TODO"
scheduler: "TODO"
augmentations:
- "TODO"
hardware: "TODO"
evaluation:
metrics:
- "TODO"
protocol: "TODO"
baselines:
- "TODO"
evidence_status:
code_found: false
local_code_found: false
reproducibility: "TODO"
missing_fields:
- "TODO"
assumptions:
- "ASSUMPTION: fields marked TODO must be filled from paper evidence before running training."
"""
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(yaml, encoding="utf-8")
print("=== 执行脚本 ===")
print("脚本:build_paper_spec.py")
print(f"输入证据:{evidence_path}")
print(f"输出文件:{out}")
print(f"建议工程目录名:{implementation_slug}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/find_local_code.py
#!/usr/bin/env python3
"""Find local source repositories before cloning or creating from-scratch implementations."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
from pathlib import Path
INDICATORS = {
"git": 8,
"readme": 3,
"requirements": 2,
"pyproject": 2,
"setup": 2,
"train": 3,
"eval": 2,
"model": 2,
"dataset": 2,
"config": 2,
}
def norm(value: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
def safe_slug(value: str) -> str:
return re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-") or "paper"
def split_terms(value: str) -> list[str]:
return [t for t in norm(value).split() if len(t) >= 2]
def run_origin(path: Path) -> str:
if not (path / ".git").exists():
return ""
try:
proc = subprocess.run(["git", "-C", str(path), "remote", "get-url", "origin"], text=True,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10)
return proc.stdout.strip() if proc.returncode == 0 else ""
except Exception:
return ""
def is_repo_like(path: Path) -> bool:
if not path.is_dir():
return False
names = {p.name.lower() for p in path.iterdir() if p.exists()}
if ".git" in names:
return True
if any(name.startswith("readme") for name in names):
return True
if any(name in names for name in ["requirements.txt", "pyproject.toml", "setup.py", "environment.yml", "environment.yaml"]):
return True
if any(name in names for name in ["src", "models", "model", "datasets", "data", "configs", "config"]):
return True
return False
def score_repo(path: Path, terms: list[str], repo_url: str = "") -> tuple[int, list[str], str]:
score = 0
reasons: list[str] = []
name_norm = norm(path.name)
for term in terms:
if term in name_norm:
score += 5
reasons.append(f"目录名匹配:{term}")
origin = run_origin(path)
if repo_url and origin and origin.rstrip("/").lower() == repo_url.rstrip("/").lower():
score += 30
reasons.append("git origin 与目标 URL 一致")
if (path / ".git").exists():
score += INDICATORS["git"]
reasons.append("包含 .git")
for child in path.iterdir() if path.exists() else []:
lower = child.name.lower()
if lower.startswith("readme"):
score += INDICATORS["readme"]
reasons.append(f"包含 {child.name}")
if lower.startswith("requirements") or lower.startswith("environment"):
score += INDICATORS["requirements"]
reasons.append(f"包含依赖文件 {child.name}")
if lower == "pyproject.toml":
score += INDICATORS["pyproject"]
reasons.append("包含 pyproject.toml")
if lower in {"src", "models", "model"}:
score += INDICATORS["model"]
reasons.append(f"包含模型/源码目录 {child.name}")
if lower in {"datasets", "dataset", "data"}:
score += INDICATORS["dataset"]
reasons.append(f"包含数据目录 {child.name}")
if lower in {"configs", "config"}:
score += INDICATORS["config"]
reasons.append(f"包含配置目录 {child.name}")
if re.match(r"^(train|main|run|eval|test).*\.(py|ipynb|sh|cmd)$", lower):
score += INDICATORS["train"]
reasons.append(f"包含入口候选 {child.name}")
return score, reasons, origin
def gather_roots(workspace: Path, paper_slug: str, extra_roots: list[str]) -> list[Path]:
roots = [
workspace / "paper-repro-workspace" / paper_slug / "main-code",
workspace / "paper-repro-workspace" / paper_slug / "dataset-code",
workspace / "paper-repro-workspace" / paper_slug / "local-code",
workspace,
]
env_roots = os.environ.get("PAPER_REPRO_LOCAL_CODE_ROOTS", "")
for item in re.split(r"[;:]", env_roots):
if item.strip():
roots.append(Path(item.strip()))
roots.extend(Path(r) for r in extra_roots)
unique: list[Path] = []
seen: set[str] = set()
for root in roots:
try:
key = str(root.resolve())
except Exception:
key = str(root)
if key not in seen:
seen.add(key)
unique.append(root)
return unique
def walk_candidates(root: Path, max_depth: int) -> list[Path]:
out: list[Path] = []
if not root.exists() or not root.is_dir():
return out
root = root.resolve()
stack = [(root, 0)]
while stack:
path, depth = stack.pop()
if path.name in {".git", "__pycache__", ".venv", "node_modules"}:
continue
if is_repo_like(path):
out.append(path)
if (path / ".git").exists() and path != root:
continue
if depth < max_depth:
try:
children = [p for p in path.iterdir() if p.is_dir()]
except OSError:
children = []
stack.extend((child, depth + 1) for child in children)
return out
def main() -> int:
parser = argparse.ArgumentParser(description="Find local source code candidates")
parser.add_argument("--name", action="append", default=[], help="paper, method, dataset, or repo name")
parser.add_argument("--repo-url", default="")
parser.add_argument("--paper-slug", default="paper")
parser.add_argument("--workspace", default=".")
parser.add_argument("--root", action="append", default=[])
parser.add_argument("--max-depth", type=int, default=4)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
paper_slug = safe_slug(args.paper_slug)
terms: list[str] = []
for name in args.name:
terms.extend(split_terms(name))
if args.repo_url:
repo_tail = args.repo_url.rstrip("/").split("/")[-1].replace(".git", "")
terms.extend(split_terms(repo_tail))
terms = sorted(set(terms))
roots = gather_roots(Path(args.workspace), paper_slug, args.root)
results = []
seen: set[str] = set()
for root in roots:
for cand in walk_candidates(root, args.max_depth):
key = str(cand.resolve())
if key in seen:
continue
seen.add(key)
score, reasons, origin = score_repo(cand, terms, args.repo_url)
if score <= 0 and terms:
continue
results.append({
"path": str(cand),
"score": score,
"origin": origin,
"reasons": reasons,
})
results.sort(key=lambda r: r["score"], reverse=True)
payload = {"script": "find_local_code.py", "terms": terms, "roots": [str(r) for r in roots], "results": results[:20]}
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print("=== 执行脚本 ===")
print("脚本:find_local_code.py")
print(f"检索词:{', '.join(terms) if terms else '(无)'}")
print("=== 本地源码候选 ===")
if not results:
print("未找到高相关本地源码候选。")
for item in results[:20]:
print(f"score={item['score']} path={item['path']}")
if item["origin"]:
print(f" origin={item['origin']}")
for reason in item["reasons"][:8]:
print(f" - {reason}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/inspect_repo_data_processing.py
#!/usr/bin/env python3
"""Inspect repository files and identify likely data processing code."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
KEYWORDS = [
"dataset", "dataloader", "data_loader", "datamodule", "preprocess", "prepare",
"process", "processing", "transform", "augment", "crop", "resize", "split",
"annotation", "label", "metadata", "extract", "feature", "frames", "tokenize",
"download", "convert", "benchmark", "loader", "sampler",
]
CODE_EXTS = {".py", ".ipynb", ".sh", ".cmd", ".ps1", ".yaml", ".yml", ".json", ".txt", ".md"}
SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", "dist", "build"}
DEF_RE = re.compile(r"^\s*(class|def)\s+([A-Za-z_][A-Za-z0-9_]*)")
SECTION_RE = re.compile(r"^#{1,4}\s+(.+)")
def rel(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root)).replace("\\", "/")
except ValueError:
return str(path)
def read_text(path: Path, limit: int = 200_000) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace")[:limit]
except Exception:
return ""
def score_path(path: Path, text: str) -> tuple[int, list[str]]:
lower_path = str(path).lower().replace("\\", "/")
lower_text = text.lower()
score = 0
reasons: list[str] = []
for kw in KEYWORDS:
if kw in lower_path:
score += 5
reasons.append(f"路径包含 {kw}")
count = lower_text.count(kw)
if count:
score += min(count, 5)
if "torch.utils.data" in lower_text:
score += 12
reasons.append("包含 torch.utils.data")
if "class " in text and "dataset" in lower_text:
score += 8
reasons.append("可能定义 Dataset 类")
if "if __name__" in lower_text and any(k in lower_text for k in ["preprocess", "prepare", "dataset", "data"]):
score += 5
reasons.append("可能是可执行数据处理脚本")
return score, reasons
def extract_symbols(text: str, limit: int = 20) -> list[str]:
out: list[str] = []
for line in text.splitlines():
m = DEF_RE.match(line)
if m:
out.append(f"{m.group(1)} {m.group(2)}")
if len(out) >= limit:
break
return out
def extract_readme_sections(path: Path, text: str) -> list[str]:
lines = text.splitlines()
sections: list[str] = []
capture = False
buf: list[str] = []
title = ""
for line in lines:
m = SECTION_RE.match(line)
if m:
if capture and buf:
sections.append(title + "\n" + "\n".join(buf[:20]))
title = line
capture = any(k in m.group(1).lower() for k in ["data", "dataset", "preprocess", "prepare", "download", "training", "benchmark"])
buf = []
elif capture:
buf.append(line)
if capture and buf:
sections.append(title + "\n" + "\n".join(buf[:20]))
return sections[:6]
def main() -> int:
parser = argparse.ArgumentParser(description="Locate data processing code in a repository")
parser.add_argument("repo_path")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
root = Path(args.repo_path)
if not root.exists():
print(f"错误:路径不存在:{root}")
return 2
candidates = []
readme_sections = []
for path in root.rglob("*"):
if any(part in SKIP_DIRS for part in path.parts):
continue
if not path.is_file() or path.suffix.lower() not in CODE_EXTS:
continue
text = read_text(path)
score, reasons = score_path(path, text)
if path.name.lower().startswith("readme"):
readme_sections.extend({"file": rel(path, root), "section": s} for s in extract_readme_sections(path, text))
if score >= 6:
candidates.append({
"path": rel(path, root),
"score": score,
"reasons": reasons[:8],
"symbols": extract_symbols(text),
})
candidates.sort(key=lambda x: x["score"], reverse=True)
payload = {"script": "inspect_repo_data_processing.py", "repo_path": str(root), "candidates": candidates[:50], "readme_sections": readme_sections[:10]}
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print("=== 执行脚本 ===")
print("脚本:inspect_repo_data_processing.py")
print(f"仓库路径:{root}")
print("\n=== 数据处理代码候选 ===")
if not candidates:
print("未定位到明显数据处理代码候选。")
for item in candidates[:30]:
print(f"score={item['score']} file={item['path']}")
for r in item["reasons"]:
print(f" - {r}")
if item["symbols"]:
print(f" symbols: {', '.join(item['symbols'][:12])}")
print("\n=== README 数据相关章节候选 ===")
if not readme_sections:
print("未定位到 README 数据相关章节。")
for sec in readme_sections[:6]:
print(f"--- {sec['file']} ---")
print(sec["section"][:1200])
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/inspect_repro_project.py
#!/usr/bin/env python3
"""Inspect a generated reproduction project without installing dependencies or training."""
from __future__ import annotations
import argparse
import py_compile
from pathlib import Path
REQUIRED = [
"README.md",
"repro-docs/repro-notes.md",
"repro-docs/evidence-map.md",
"repro-docs/paper-spec.yaml",
"repro-docs/requirements.txt",
"config.py",
"main.py",
"run.py",
"data/__init__.py",
"data/dataset.py",
"data/preprocess.py",
"models/__init__.py",
"models/model.py",
"engine/__init__.py",
"engine/train.py",
"engine/evaluate.py",
"utils/__init__.py",
"utils/common.py",
"utils/metrics.py",
]
FORBIDDEN_DEFAULTS = [
"configs/default.yaml",
"configs/debug.yaml",
"configs/ablation.yaml",
"losses/paper_loss.py",
"loss.py",
"scripts/train.sh",
"scripts/eval.sh",
"scripts/train.cmd",
"scripts/eval.cmd",
]
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect generated concise repro project")
parser.add_argument("project_path")
args = parser.parse_args()
root = Path(args.project_path)
print("=== 执行脚本 ===")
print("脚本:inspect_repro_project.py")
print(f"工程路径:{root}")
if not root.exists():
print("错误:工程路径不存在。")
return 2
print("\n=== 必需文件检查 ===")
missing = []
for rel in REQUIRED:
path = root / rel
if path.exists():
print(f"OK {rel}")
else:
print(f"MISSING {rel}")
missing.append(rel)
print("\n=== 不应默认生成的旧结构检查 ===")
forbidden_found = []
for rel in FORBIDDEN_DEFAULTS:
path = root / rel
if path.exists():
print(f"FOUND_OLD_STRUCTURE {rel}")
forbidden_found.append(rel)
else:
print(f"OK_ABSENT {rel}")
print("\n=== Python 静态编译检查 ===")
compile_errors = []
for path in sorted(root.rglob("*.py")):
try:
py_compile.compile(str(path), doraise=True)
print(f"OK {path.relative_to(root)}")
except Exception as exc:
print(f"FAIL {path.relative_to(root)}: {exc}")
compile_errors.append(str(path.relative_to(root)))
print("\n=== TODO / ASSUMPTION / NotImplementedError 统计 ===")
markers = {"TODO": 0, "ASSUMPTION": 0, "NotImplementedError": 0}
marker_files = []
for path in root.rglob("*"):
if path.is_file() and path.suffix.lower() in {".py", ".md", ".yaml", ".yml", ".txt"}:
text = read(path)
counts = {k: text.count(k) for k in markers}
if any(counts.values()):
marker_files.append((str(path.relative_to(root)).replace("\\", "/"), counts))
for k, v in counts.items():
markers[k] += v
for k, v in markers.items():
print(f"{k}: {v}")
for file, counts in marker_files[:50]:
parts = ", ".join(f"{k}={v}" for k, v in counts.items() if v)
print(f"- {file}: {parts}")
print("\n=== 结论 ===")
if missing or compile_errors or forbidden_found:
print("状态:部分通过")
if missing:
print("缺失文件:" + ", ".join(missing))
if compile_errors:
print("编译失败:" + ", ".join(compile_errors))
if forbidden_found:
print("发现旧结构:" + ", ".join(forbidden_found))
return 1
print("状态:通过静态检查。未安装依赖,未下载数据,未运行训练。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/scaffold_repro_project.py
#!/usr/bin/env python3
"""Generate a concise, structured PyTorch reproduction project from paper-spec.yaml.
The generated layout is intentionally close to many small/medium research repos:
main.py + config.py + run.py at the root, with data/, models/, engine/ and utils/
subpackages. It avoids multiple YAML configs, shell scripts, and a separate losses
package by default.
"""
from __future__ import annotations
import argparse
import re
import shutil
from pathlib import Path
def parse_simple_yaml(path: Path) -> dict[str, str]:
"""Tiny YAML-ish parser for scalar values used by this skill.
This avoids external dependencies. It is not a general YAML parser.
"""
data: dict[str, str] = {}
stack: list[str] = []
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
line = raw.strip()
if ":" not in line or line.startswith("-"):
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"\'')
level = indent // 2
stack = stack[:level]
stack.append(key)
if value:
data[".".join(stack)] = value.split(" # ")[0].strip().strip('"\'')
return data
def slugify(value: str, default: str = "paper") -> str:
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return value[:80] or default
def class_name(value: str, default: str = "PaperModel") -> str:
words = re.findall(r"[a-zA-Z0-9]+", value)
if not words:
return default
name = "".join(w[:1].upper() + w[1:] for w in words)
if name and name[0].isdigit():
name = "Paper" + name
return name or default
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Scaffold concise structured PyTorch reproduction project")
parser.add_argument("paper_spec")
parser.add_argument("--out", default="")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
spec_path = Path(args.paper_spec)
if not spec_path.exists():
print(f"错误:paper spec 不存在:{spec_path}")
return 2
spec = parse_simple_yaml(spec_path)
title = spec.get("paper.title", "UNKNOWN")
method = spec.get("paper.method_name") or spec.get("paper.task") or "paper"
implementation_slug = spec.get("paper.implementation_slug") or f"{slugify(method, 'paper')}-reproduction"
out = Path(args.out) if args.out else (spec_path.parent / implementation_slug)
model_cls = class_name(method)
if out.exists() and any(out.iterdir()) and not args.force:
print(f"错误:输出目录已存在且非空:{out}")
print("为避免覆盖,不会生成。请指定新目录或手动清理。")
return 3
out.mkdir(parents=True, exist_ok=True)
(out / "repro-docs").mkdir(parents=True, exist_ok=True)
shutil.copyfile(spec_path, out / "repro-docs" / "paper-spec.yaml")
for pkg in ["data", "models", "engine", "utils"]:
write(out / pkg / "__init__.py", "")
write(out / "repro-docs" / "requirements.txt", """torch
numpy
tqdm
""")
write(out / "README.md", f"""# {title} - Reproduction Scaffold
This project was generated by `paper-repro-triage` because no official/local main-paper source code was found.
It is a conservative PyTorch scaffold, not a claim of successful reproduction.
## Layout
- `main.py`: command-line entrypoint.
- `config.py`: argparse defaults and hyperparameters.
- `run.py`: dispatches preprocess/train/eval/inference by mode.
- `data/`: dataset loading and preprocessing.
- `models/`: paper model definition.
- `engine/`: training and evaluation loops.
- `utils/`: metrics, seed, path and JSON helpers.
## repro-docs
- `requirements.txt`: minimal dependency list.
- `paper-spec.yaml`: structured paper evidence used to generate this scaffold; it is not the training config.
- `evidence-map.md`: mapping from generated code files to paper evidence, dataset-code evidence or explicit assumptions.
- `repro-notes.md`: limitations, missing details and manual checks before real training.
## Commands
```cmd
python -m pip install -r repro-docs/requirements.txt
python main.py --mode preprocess --dataset paper --data_root ./data
python main.py --mode train --dataset paper --data_root ./data
python main.py --mode eval --checkpoint outputs/best.pt
```
## Safety notes
- This scaffold does not download datasets automatically.
- This scaffold does not install dependencies automatically.
- Paper-missing details are marked as `ASSUMPTION` or `TODO`.
- Run small smoke tests before real training.
""")
write(out / "repro-docs" / "repro-notes.md", f"""# Reproduction Notes
This file records what is still uncertain or unverified. It is the place for reproduction limitations, assumptions, missing data, and manual checks. Do not treat this scaffold as a completed paper reproduction until these notes are resolved.
## Status
- Source code found: false
- Local main-paper source code found: false
- Generated implementation directory: `{out.name}`
## Important limitations
- This code is a scaffold generated from paper evidence and explicit assumptions.
- Do not report paper-level reproduction results until real data, dependencies, training and evaluation have been confirmed.
## Assumptions to verify
- Model dimensions and module details marked TODO.
- Dataset file layout and preprocessing steps marked TODO.
- Loss weights and scheduler details marked TODO unless paper-spec.yaml states them.
""")
write(out / "repro-docs" / "evidence-map.md", """# Evidence Map
This file links generated code to paper evidence, dataset-code evidence, baseline-code evidence, or explicit assumptions. If a code choice is not supported by the paper, mark it as `ASSUMPTION` or `TODO`.
| Code file | Purpose | Paper evidence | Assumption / TODO |
|---|---|---|---|
| config.py | Command-line args and hyperparameters | paper-spec.yaml training/evaluation sections | TODO fields require paper evidence |
| main.py | CLI entrypoint | common research-code pattern | no paper-specific assumption |
| run.py | mode dispatch | paper task flow | no paper-specific assumption |
| data/dataset.py | Dataset loader | datasets/preprocessing section | TODO: actual file layout |
| data/preprocess.py | Data processing hooks | dataset-source-tracing results | TODO: adapt to actual dataset |
| models/model.py | Model definition | architecture section | ASSUMPTION if architecture details missing |
| engine/train.py | Training loop and loss/objective | training + loss_terms section | ASSUMPTION for missing hyperparameters |
| engine/evaluate.py | Evaluation loop | evaluation section | TODO: exact metrics if missing |
| utils/metrics.py | Metrics | evaluation metrics section | TODO if metric undefined |
""")
write(out / "config.py", f'''import argparse
def build_parser():
parser = argparse.ArgumentParser(description="{title} reproduction scaffold")
parser.add_argument('--dataset', default='paper', help='dataset name or alias')
parser.add_argument('--mode', default='train', choices=['preprocess', 'train', 'eval', 'inference'])
parser.add_argument('--data_root', default='./data')
parser.add_argument('--output_dir', default='./outputs')
parser.add_argument('--checkpoint', default='', help='checkpoint path for eval/inference')
parser.add_argument('--epochs', type=int, default=1, help='ASSUMPTION: debug default; replace with paper value')
parser.add_argument('--batch_size', type=int, default=32, help='ASSUMPTION unless paper specifies')
parser.add_argument('--lr', type=float, default=1e-3, help='ASSUMPTION unless paper specifies')
parser.add_argument('--weight_decay', type=float, default=0.0)
parser.add_argument('--num_workers', type=int, default=0)
parser.add_argument('--seed', type=int, default=42)
parser.add_argument('--gpu', default='0')
parser.add_argument('--hidden_dim', type=int, default=256, help='TODO/ASSUMPTION: replace with paper value')
parser.add_argument('--input_dim', type=int, default=128, help='TODO/ASSUMPTION: replace with dataset feature dimension')
parser.add_argument('--output_dim', type=int, default=2, help='TODO/ASSUMPTION: replace with task output size')
parser.add_argument('--alpha', type=float, default=1.0, help='optional paper-specific loss weight')
parser.add_argument('--beta', type=float, default=1.0, help='optional paper-specific loss weight')
return parser
def parse_args(argv=None):
return build_parser().parse_args(argv)
''')
write(out / "main.py", '''import os
from config import parse_args
from run import Run
from utils.common import set_seed
if __name__ == '__main__':
args = parse_args()
# Set visible GPU before creating CUDA contexts.
os.environ['CUDA_VISIBLE_DEVICES'] = str(args.gpu)
set_seed(args.seed)
print(args)
Run(args).main()
''')
write(out / "run.py", '''from data.preprocess import preprocess
from engine.train import train
from engine.evaluate import evaluate
class Run:
def __init__(self, args):
self.args = args
def main(self):
if self.args.mode == 'preprocess':
return preprocess(self.args)
if self.args.mode == 'train':
return train(self.args)
if self.args.mode in {'eval', 'inference'}:
return evaluate(self.args)
raise ValueError(f'Unsupported mode: {self.args.mode}')
''')
write(out / "utils" / "common.py", '''import json
import random
from pathlib import Path
import numpy as np
import torch
def set_seed(seed: int):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
def get_device():
return torch.device('cuda' if torch.cuda.is_available() else 'cpu')
def ensure_dir(path):
Path(path).mkdir(parents=True, exist_ok=True)
def save_json(obj, path):
path = Path(path)
ensure_dir(path.parent)
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
''')
write(out / "data" / "dataset.py", '''# Data processing evidence:
# - Fill this section with dataset-source-tracing results.
# - If source repo processing files were found, list them here.
# TODO: Replace synthetic fallback with actual paper dataset parsing.
from pathlib import Path
import torch
from torch.utils.data import DataLoader, Dataset
class PaperDataset(Dataset):
def __init__(self, root, split='train'):
self.root = Path(root)
self.split = split
# TODO: Replace synthetic fallback with actual annotation/index loading.
self.items = list(range(8))
def __len__(self):
return len(self.items)
def __getitem__(self, index):
# ASSUMPTION: vector input fallback for smoke checking.
x = torch.randn(128)
y = torch.tensor(index % 2, dtype=torch.long)
return {'input': x, 'label': y}
def build_dataloader(args, split='train'):
dataset = PaperDataset(args.data_root, split=split)
shuffle = split == 'train'
return DataLoader(
dataset,
batch_size=args.batch_size,
shuffle=shuffle,
num_workers=args.num_workers,
)
''')
write(out / "data" / "preprocess.py", '''from pathlib import Path
# TODO: Implement dataset-specific preprocessing based on paper evidence and
# dataset-source-tracing results. Do not download restricted datasets here.
def preprocess(args):
data_root = Path(args.data_root)
data_root.mkdir(parents=True, exist_ok=True)
print(f'Preprocess placeholder. Data root: {data_root}')
print('TODO: add annotation conversion, tokenizer/vocab construction, frame extraction, or feature extraction.')
''')
write(out / "models" / "model.py", f'''# Model evidence:
# - Method name from paper-spec: {method}
# - TODO: Replace fallback MLP with paper-specific architecture.
from torch import nn
class {model_cls}(nn.Module):
def __init__(self, args):
super().__init__()
self.net = nn.Sequential(
nn.Linear(args.input_dim, args.hidden_dim),
nn.ReLU(),
nn.Linear(args.hidden_dim, args.output_dim),
)
def forward(self, batch):
x = batch['input']
return self.net(x)
def build_model(args):
return {model_cls}(args)
''')
write(out / "utils" / "metrics.py", '''import torch
def accuracy(logits, labels):
# TODO: Replace or extend with exact paper metrics.
preds = torch.argmax(logits, dim=-1)
return (preds == labels).float().mean().item()
''')
write(out / "engine" / "train.py", '''import torch
from torch import nn
from torch.optim import Adam
from tqdm import tqdm
from data.dataset import build_dataloader
from models.model import build_model
from utils.common import ensure_dir, get_device
from utils.metrics import accuracy
def build_criterion(args):
# TODO: Replace with paper-specific objective if different.
# Keep loss here unless the paper has a complex reusable loss module.
return nn.CrossEntropyLoss()
def train(args):
device = get_device()
ensure_dir(args.output_dir)
train_loader = build_dataloader(args, split='train')
model = build_model(args).to(device)
criterion = build_criterion(args)
optimizer = Adam(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
best_path = f'{args.output_dir}/best.pt'
for epoch in range(args.epochs):
model.train()
total_loss = 0.0
total_acc = 0.0
steps = 0
for batch in tqdm(train_loader, desc=f'epoch {epoch + 1}/{args.epochs}'):
batch = {k: v.to(device) if hasattr(v, 'to') else v for k, v in batch.items()}
logits = model(batch)
loss = criterion(logits, batch['label'])
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
total_acc += accuracy(logits.detach(), batch['label'])
steps += 1
print({'epoch': epoch + 1, 'loss': total_loss / max(steps, 1), 'accuracy': total_acc / max(steps, 1)})
torch.save({'model': model.state_dict(), 'args': vars(args)}, best_path)
print(f'Saved checkpoint: {best_path}')
''')
write(out / "engine" / "evaluate.py", '''import torch
from data.dataset import build_dataloader
from models.model import build_model
from utils.common import get_device, save_json
from utils.metrics import accuracy
def evaluate(args):
device = get_device()
loader = build_dataloader(args, split='test')
model = build_model(args).to(device)
if args.checkpoint:
ckpt = torch.load(args.checkpoint, map_location=device)
state = ckpt.get('model', ckpt)
model.load_state_dict(state, strict=False)
else:
print('WARNING: no checkpoint provided; evaluating randomly initialized model.')
model.eval()
scores = []
with torch.no_grad():
for batch in loader:
batch = {k: v.to(device) if hasattr(v, 'to') else v for k, v in batch.items()}
logits = model(batch)
scores.append(accuracy(logits, batch['label']))
result = {'accuracy_placeholder': sum(scores) / max(len(scores), 1)}
save_json(result, f'{args.output_dir}/eval.json')
print(result)
''')
print("=== 执行脚本 ===")
print("脚本:scaffold_repro_project.py")
print(f"论文:{title}")
print(f"方法:{method}")
print(f"工程路径:{out}")
print("结构:base layout (repro-docs/, main.py, config.py, run.py, data/, models/, engine/, utils/); can be extended when the paper requires")
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:references/dataset-source-tracing.md
# 数据集论文与数据集源码溯源流程
目标:从主论文中识别关键数据集,轻量检索这些数据集的原论文或项目页是否有官方源码、处理代码或 benchmark 仓库。如果找到可信仓库,先查本地是否已有相关源码;没有才自动 clone。不下载论文 PDF,不下载数据集本体。
## 触发条件
主论文中出现以下信息时触发:
- 使用多个公开数据集进行实验。
- 发布新数据集、benchmark 或标注资源。
- 数据集对复现结论至关重要。
- 用户明确要求“爬取相关数据集论文”“找数据集论文源码”“克隆数据集代码”。
## 数据集优先级
如果数据集很多,默认最多处理 5 个:
1. 主实验表格中反复出现的数据集。
2. 论文新发布的数据集。
3. 与核心结论直接相关的数据集。
4. 需要特殊预处理或标注流程的数据集。
5. benchmark 评测协议依赖的数据集。
## 检索策略
对每个数据集,优先查:
- 主论文参考文献里的数据集原论文标题。
- 数据集名称 + `github`。
- 数据集名称 + `official code`。
- 数据集名称 + `project page`。
- 数据集论文标题 + `code`。
- Papers with Code 数据集页或任务页。
- arXiv abstract 页面里的 comments/code 链接。
## 本地优先规则
找到数据集相关仓库 URL 后,clone 前必须先检查本地已有源码:
```text
python scripts/find_local_code.py --paper-slug <paper-slug> --name <dataset-name-or-repo-name> --workspace .
```
检查范围:
- `paper-repro-workspace/<paper-slug>/dataset-code/`
- `paper-repro-workspace/<paper-slug>/local-code/`
- 当前 agent workspace
- 环境变量 `PAPER_REPRO_LOCAL_CODE_ROOTS`
如果本地存在同名或高相关源码,记录为 `本地已存在,跳过 clone`,并直接进入源码导读和数据处理代码定位。
## 仓库可信度判断
- 官方:论文作者、项目组织、数据集官网明确链接。
- 可能官方:作者主页或机构页链接,但没有明确“official”字样。
- 第三方:非作者维护、复现性质、社区实现。
- 未验证:只能通过搜索结果推测,缺少直接证据。
只自动 clone “官方”或“可能官方”的仓库。第三方仓库默认只记录 URL,不自动 clone,除非用户明确同意。
## 数据处理代码定位
对每个已经找到或本地存在的数据集源码,必须定位数据处理相关代码。优先使用:
```text
python scripts/inspect_repo_data_processing.py <repo-path>
```
必须检查并记录:
- 数据集类:`Dataset`、`DataModule`、`DataLoader`、`torch.utils.data.Dataset`。
- 数据加载文件:`dataset.py`、`datasets/*.py`、`data/*.py`、`loader.py`、`dataloader.py`。
- 预处理脚本:`preprocess.py`、`prepare_data.py`、`process_*.py`、`extract_*.py`、`convert_*.py`。
- 标注解析:`annotation`、`label`、`split`、`metadata`、`json/csv/txt` parsing。
- 特征抽取:`feature`、`extract_frames`、`tokenize`、`crop`、`resize`、`augment`。
- README / docs 中的 data preparation、preprocess、dataset setup 命令。
如果无法明确判断数据处理入口,要报告“未定位到明确数据处理入口”,并说明仅找到哪些候选文件。
## 报告字段
每个数据集至少记录:
- 数据集名称。
- 是否是主实验依赖。
- 原论文或项目页。
- 是否找到源码。
- 仓库 URL。
- 仓库可信度。
- clone / 本地状态。
- 本地路径。
- 数据处理代码位置。
- 数据处理入口命令或函数。
- README / 文件证据。
- 数据访问限制。
- 对主论文复现的影响。
## 禁止事项
- 不下载数据集本体。
- 不批量下载论文 PDF。
- 不绕过登录、申请、验证码、付费墙或授权限制。
- 不把第三方复现仓库伪装成官方仓库。
- 不声称数据集可用,除非找到明确下载或申请路径。
## 重复目录处理
当数据集源码仓库需要 clone 到 `paper-repro-workspace/<paper-slug>/dataset-code/<dataset-slug>/<repo-name>/` 时,必须先检查目标路径是否已经存在同名源码文件夹。
- 如果目标路径不存在:可以自动 `git clone`。
- 如果目标路径已经存在:不要再次 clone,不要自动 `git pull`,不要覆盖,也不要改用时间戳新目录;直接读取现有目录并在报告中标记 `已存在,跳过 clone`。
- 如果现有目录是 git 仓库:记录其 `origin`,并说明是否与目标仓库一致。
- 如果现有目录不是 git 仓库:记录目录冲突,提示用户手动处理或指定新目录。
聊天极简摘要中也必须体现数据集源码状态,例如:`数据集源码:本地已存在 1 个,已存在,跳过 clone 2 个`。
FILE:references/no-code-reproduction.md
# 无主论文源码时的复现工程生成流程
当主论文没有官方源码、线上没有可信主仓库、本地也没有主论文源码时,本技能进入无主论文源码复现路径。目标不是伪造完整结果,而是在证据足够时生成一个符合多数 PyTorch 论文仓库直觉、可审查、可继续开发的最小复现工程。
## 进入本流程前的硬性前提
本流程只在“无主论文源码”时进入。如果主论文官方/高度可信源码已经 clone、已存在或本地已找到,必须停止在仓库导读和报告阶段,不得生成新的复现工程,也不得运行训练。数据集源码、baseline 源码或相关论文源码不算主论文源码;它们不能阻止本流程。
## 生成条件
同时满足以下条件时必须生成复现工程:
- 论文是方法论文、系统论文、可执行 benchmark 方法,或资源论文中的 benchmark 使用流程。
- 可复现性结论为“可以直接复现”或“部分可复现”。
- 论文中能提取出最小可行实现所需信息:输入、输出、模型或流程模块、loss/objective、数据集/替代数据、评测指标。
- 缺失信息可以用明确假设补齐,并且不会改变论文核心方法。
如果只找到数据集相关源码、baseline 源码或旧方法源码,仍然要继续生成主论文复现工程。它们只能作为数据处理、baseline 或实现参考证据,不能替代主论文源码。
## 不生成 paper reproduction 的情况
以下情况不能生成 paper reproduction,只能生成 baseline 或实验设计记录:
- 综述、观点或理论分析为主。
- 关键数据、权重、系统或闭源 API 不可获得,且没有合理缩小版。
- 模型结构、loss、评测协议都不清楚。
- 论文目标是 benchmark 定义而不是方法复现。
如果能构造一个合理 baseline,但不能构造论文方法,目录名必须包含 `baseline`,并在报告中说明不是论文原方法复现。
## 目录命名
工程目录不得固定为 `repro-implementation`。根据论文框架、方法、模型或任务名生成:
```text
paper-repro-workspace/<paper-slug>/<framework-or-method-slug>-reproduction/
```
示例:
- SGAN:`sgan-reproduction/`
- MI2LaTeX:`mi2latex-reproduction/`
- Diffusion Transformer:`dit-reproduction/`
- VideoMAE:`videomae-reproduction/`
- 无法判断方法名:`<paper-slug>-reproduction/`
## 最低基本盘工程结构
默认生成“有结构但不重”的 PyTorch 工程。基本盘采用根目录入口文件 + 四个职责明确的代码目录 + 一个复现文档目录。该结构是最低推荐结构,不是不可更改的硬性模板;如果论文需要 tokenizer、decoder、retrieval、beam search、多阶段训练或特殊评测,可以在此基础上增加目录或文件。
```text
<method-slug>-reproduction/
├── README.md
├── repro-docs/
│ ├── requirements.txt
│ ├── paper-spec.yaml
│ ├── evidence-map.md
│ └── repro-notes.md
├── config.py
├── main.py
├── run.py
├── data/
│ ├── __init__.py
│ ├── dataset.py
│ └── preprocess.py
├── models/
│ ├── __init__.py
│ └── model.py
├── engine/
│ ├── __init__.py
│ ├── train.py
│ └── evaluate.py
└── utils/
├── __init__.py
├── common.py
└── metrics.py
```
`repro-docs/` 只存放复现文档和依赖清单,不放训练入口代码。根目录保留 `main.py`、`config.py`、`run.py`,让用户一眼看到如何运行;真正实现放入 `data/`、`models/`、`engine/`、`utils/`。
## `repro-docs/` 四个文件的作用
### `repro-docs/requirements.txt`
作用:记录最小依赖清单,方便用户创建环境。只写运行脚手架和最小复现所需依赖,例如 `torch`、`numpy`、`tqdm`。不要把尚未验证的重型依赖、系统依赖或数据下载工具随意塞进去;如需特殊依赖,在 `repro-notes.md` 中说明待确认。
### `repro-docs/paper-spec.yaml`
作用:记录从论文中抽取出的结构化规格,是生成代码的证据输入,不是训练配置文件。它应包含任务、模型模块、输入输出、数据集、loss、训练超参数、评测指标、缺失项和假设。训练时的命令行参数仍由 `config.py` 管理。
### `repro-docs/evidence-map.md`
作用:记录“代码文件 ↔ 论文证据/数据集源码证据/明确假设”的映射。每个生成的核心文件都要能追溯依据,例如 `models/model.py` 来自方法章节或架构图,`data/preprocess.py` 来自数据集论文源码或主论文预处理描述。它用于防止把推测伪装成论文事实。
### `repro-docs/repro-notes.md`
作用:记录复现状态、限制、缺失信息和人工确认项。包括尚未下载的数据、没有安装的依赖、论文没给出的超参数、只能做 skeleton 的原因、数据访问限制、以及运行真实训练前必须确认的事项。
## 不默认生成的结构
不要默认生成以下内容,除非论文或用户明确需要:
- `configs/default.yaml`、`configs/debug.yaml`、`configs/ablation.yaml`:默认只用 `config.py` + argparse。复杂项目才可扩展配置文件。
- `losses/` 或 `loss.py`:默认把 loss 放在 `engine/train.py` 的 `build_criterion()` 中;只有多个可复用复杂 loss 时才拆出。
- `scripts/train.sh`、`scripts/eval.sh`、`.cmd`:默认只用 Python 命令行入口。
- `tests/`:默认不生成;如果用户要求工程测试,再生成 `tests/`。静态检查由 skill 自带 `inspect_repro_project.py` 完成。
## 代码文件职责
### `config.py`
唯一配置入口。使用 `argparse` 定义常用命令行参数、默认超参数和路径。不要默认生成多个 YAML 配置文件。
必须包含:
- `--mode train/eval/inference/preprocess`
- `--dataset`
- `--data_root`
- `--output_dir`
- `--epochs`
- `--batch_size`
- `--lr`
- `--seed`
- `--gpu`
- `--checkpoint`
- 论文特有参数,例如 `--alpha`、`--beta`、`--beam_size`、`--max_len` 等
论文未给出的默认值必须注释 `ASSUMPTION`。
### `main.py`
唯一命令行入口。负责解析参数、设置随机种子和 GPU,然后把配置交给 `Run(args).main()`。
推荐命令:
```cmd
python main.py --mode preprocess --dataset <dataset> --data_root <path>
python main.py --mode train --dataset <dataset> --data_root <path>
python main.py --mode eval --checkpoint outputs/best.pt
```
### `run.py`
统一调度文件。根据 `args.mode` 调用 `data.preprocess`、`engine.train`、`engine.evaluate` 或 inference 逻辑。
### `data/dataset.py`
数据集读取和基础 transform。数据集处理证据来自数据集源码溯源时,必须在文件头写明:相关仓库、处理脚本、README 证据和可复用部分。
### `data/preprocess.py`
数据准备、标注转换、tokenizer/vocab 构建、图像/视频/文本预处理、数据 split 构建等。只写代码和入口,不自动下载数据集本体。
### `models/model.py`
模型定义文件。包含论文核心模型类,例如 `PaperModel` 或具体方法名模型类。若某些模块缺少论文细节,可以写 TODO 或 baseline 模块,但必须在文件头标注 `ASSUMPTION`。
### `engine/train.py`
训练循环。loss 通常放在 `build_criterion()` 或训练步骤中,除非论文 loss 极其复杂或有多个可复用组件,否则不要生成独立 `loss.py` 或 `losses/` 目录。
必须包含:dataloader、model、optimizer/scheduler、loss/objective、epoch/step 循环、checkpoint 保存、logging。
### `engine/evaluate.py`
评测逻辑。包含 checkpoint 加载、test dataloader、指标计算和 `outputs/eval.json` 保存。指标必须来自论文;论文没给出时标注 TODO。
### `utils/metrics.py`
指标函数。只存放评估指标,不存放训练 loss。
### `utils/common.py`
随机种子、路径创建、JSON 保存、device 选择、简单 logging 等通用函数。
## 静态检查
生成后运行:
```text
python scripts/inspect_repro_project.py <implementation-path>
```
允许自动执行:文件完整性检查、`py_compile`、TODO/ASSUMPTION/NotImplementedError 统计。
禁止自动执行:安装依赖、下载大数据、运行训练、评测完整数据集。
## 报告要求
报告必须写:
- 是否生成复现工程。
- 工程路径。
- 文件清单。
- `repro-docs/` 四个文件的用途。
- 每个代码文件的作用。
- 每个代码文件依据的论文证据。
- 数据集相关源码如何影响 `data/dataset.py` 和 `data/preprocess.py`。
- 哪些超参数来自论文,哪些是 ASSUMPTION。
- 未完成项/人工确认项。
FILE:references/output-template.md
# Markdown 报告模板
本文件用于指导详细报告写入 `paper-repro-workspace/<paper-slug>/repro-report.md`。聊天回复只保留极简摘要,不要把本报告全文贴到聊天里,除非文件写入失败。
# 论文复现报告:<论文标题>
生成时间:<时间>
主论文输入:<PDF / arXiv / URL / 用户文本>
报告目录:`paper-repro-workspace/<paper-slug>/`
## 1. 结论摘要
| 项目 | 结论 |
|---|---|
| 论文类型 | 综述 / 方法 / 提示词工程 / 基准评测 / 资源 / 理论 / 系统 |
| 是否需要复现 | 需要 / 不需要 / 建议只做部分复现 |
| 是否能复现 | 可以直接复现 / 部分可复现 / 不具备实际可复现性 / 不是复现目标 |
| 主论文源码 | 已 clone / 已存在,跳过 clone / 本地已存在 / 未找到 / 等待审批 / clone 失败 |
| 数据集源码 | 已 clone N 个 / 已存在,跳过 clone N 个 / 本地已存在 N 个 / 未找到 / 部分找到 / 未检索 |
| 数据处理代码 | 已定位 N 处 / 未定位 / 不适用 |
| 复现工程 | 已生成 / 仅生成 skeleton / 未生成 |
| 核心阻碍 | 一句话说明 |
| 执行边界 | 未运行训练 / 未安装依赖 / 未下载数据 / 已停在代码导读阶段 |
| 报告文件 | `paper-repro-workspace/<paper-slug>/repro-report.md` |
## 2. 论文基础信息
- 标题:
- 作者:
- 年份:
- 会议/期刊/arXiv:
- 论文链接:
- 项目页:
- 主仓库:
- 相关数据集:
## 3. 论文分类
- 主类型:
- 次类型:
- 判断依据:
## 4. 可复现性结论
- 结论:可以直接复现 / 部分可复现 / 不具备实际可复现性 / 不是复现目标
- 是否建议复现:需要 / 不需要 / 建议只做部分复现
- 原因:
## 5. 证据摘要
| 维度 | 结论 | 证据 |
|---|---|---|
| 主论文官方代码 | | |
| 本地主论文源码 | | |
| 数据集源码 | | |
| 数据处理代码 | | |
| 训练配置 | | |
| 评测协议 | | |
| 硬件需求 | | |
| 主要阻碍 | | |
## 6. 主论文代码与自动执行结果
- 是否找到主论文代码:
- 仓库可信度:官方 / 可能官方 / 第三方 / baseline / 相关代码 / 未验证
- 仓库 URL:
- 自动执行状态:已 clone / 已存在,跳过 clone / 本地已存在 / 等待审批 / 执行失败 / 无代码可执行
- 本地路径:
- 重复目录提醒:
- 执行过的命令:
### 6.1 重复目录与跳过 clone 记录
- 是否出现同名源码文件夹:是 / 否
- 跳过 clone 的仓库:
- 使用的现有本地路径:
- 现有目录是否为 git 仓库:是 / 否 / 未知
- 现有 origin:
- 是否继续完成只读仓库检查:是 / 否
### 6.2 仓库导读
- README 关键信息:
- 依赖文件:
- 配置方式:
- 命令行入口:
- 训练入口:
- 评测入口:
- 推理入口:
- 数据集准备:
- 模型实现:
- 训练逻辑:
- 论文与代码差异:
### 6.3 主论文源码存在时的停止记录
- 是否停止在代码导读阶段:是 / 否
- 是否安装依赖:否
- 是否下载数据:否
- 是否修改官方源码:否
- 是否运行训练/评估/推理:否
- 停止原因:主论文源码已存在,本技能只完成复现准备、仓库导读和报告写入;运行训练属于新的显式运行任务。
## 7. 数据集论文与数据集源码溯源
| 数据集 | 是否主实验依赖 | 原论文/项目页 | 是否找到源码 | 仓库 URL | clone 状态 | 本地路径 | 数据处理代码位置 | 数据处理入口/命令 | 对主论文复现的影响 |
|---|---|---|---|---|---|---|---|---|---|
| | | | | | 已 clone / 已存在,跳过 clone / 本地已存在 / 未 clone | | | | |
### 7.1 数据处理代码定位明细
| 来源仓库 | 文件 | 类型 | 关键函数/类 | 证据 | 可复用方式 | 风险 |
|---|---|---|---|---|---|---|
| | | dataset / preprocess / tokenizer / split / feature / benchmark | | README / 文件名 / 代码片段 | | |
### 7.2 数据集访问限制
- 需要申请的数据集:
- 闭源或私有数据:
- 只提供数据下载但无处理代码:
- 对复现的影响:
## 8. 架构或流程解读
- 图示类型:标准模型架构 / prompt 或 agent 流程 / 系统架构 / 不确定
- 模型或流程类型:
- 关键模块:
- 输入输出:
- loss / objective:
- 是否可按代码实现:
## 9. 实验配置清单
| 项目 | 论文给出的信息 | 源码/数据集代码中的信息 | 缺失或需要确认 |
|---|---|---|---|
| 数据集 | | | |
| 预处理 | | | |
| 模型 | | | |
| loss / objective | | | |
| optimizer | | | |
| learning rate | | | |
| batch size | | | |
| epoch / steps | | | |
| GPU / 显存 | | | |
| 指标 | | | |
## 10. 无主论文源码复现工程生成结果
仅在无主论文源码时填写。即使找到数据集源码或 baseline 源码,只要主论文源码不存在且部分可复现,也必须填写本节。
| 项目 | 结论 |
|---|---|
| 是否生成复现工程 | 已生成 / 仅生成 skeleton / 未生成 |
| 工程路径 | `paper-repro-workspace/<paper-slug>/<method-slug>-reproduction/` |
| 生成依据 | 论文证据 / 数据集源码证据 / baseline 证据 / 明确假设 |
| 是否通过静态检查 | 通过 / 部分通过 / 未运行 |
| 是否运行训练 | 未运行,需用户确认 |
### 10.1 生成工程结构
以下为最低基本盘结构,不是不可更改的硬性目录。生成工程至少应包含这些职责清晰的模块;如果论文需要额外模块,可以在此基础上增加目录或文件。
```text
<method-slug>-reproduction/
├── README.md
├── repro-docs/
│ ├── requirements.txt
│ ├── paper-spec.yaml
│ ├── evidence-map.md
│ └── repro-notes.md
├── config.py
├── main.py
├── run.py
├── data/
│ ├── __init__.py
│ ├── dataset.py
│ └── preprocess.py
├── models/
│ ├── __init__.py
│ └── model.py
├── engine/
│ ├── __init__.py
│ ├── train.py
│ └── evaluate.py
└── utils/
├── __init__.py
├── common.py
└── metrics.py
```
### 10.2 `repro-docs/` 文件说明
| 文件 | 主要用途 | 注意事项 |
|---|---|---|
| `repro-docs/requirements.txt` | 最小依赖清单,用于创建复现环境 | 只写已确认或最小必要依赖;重型或未确认依赖写入 `repro-notes.md` |
| `repro-docs/paper-spec.yaml` | 论文证据规格,记录任务、模型、数据集、loss、训练和评测信息 | 不是训练配置;训练参数入口仍是 `config.py` |
| `repro-docs/evidence-map.md` | 映射每个代码文件对应的论文证据、数据集源码证据或假设 | 必须区分论文事实、源码证据和 ASSUMPTION |
| `repro-docs/repro-notes.md` | 记录复现限制、缺失信息、人工确认项和运行前注意事项 | 不要把未验证内容写成已完成结果 |
### 10.3 生成代码文件清单
| 文件 | 作用 | 依据 | 是否含假设 |
|---|---|---|---|
| `README.md` | 工程说明和运行命令 | | |
| `main.py` | 命令行入口,解析参数后交给 `Run(args).main()` | | |
| `config.py` | argparse 参数和超参数默认值 | | |
| `run.py` | 按 mode 调度 preprocess/train/eval/inference | | |
| `data/dataset.py` | 数据读取与 transform | | |
| `data/preprocess.py` | 数据处理脚本 | | |
| `models/model.py` | 模型定义 | | |
| `engine/train.py` | 训练循环与 loss/objective | | |
| `engine/evaluate.py` | 评测循环 | | |
| `utils/metrics.py` | 指标函数 | | |
| `utils/common.py` | seed、路径、日志等工具 | | |
### 10.4 config 参数
| 参数 | 默认值 | 来源 | 备注 |
|---|---|---|---|
| | | paper / dataset-code / baseline-code / assumption / todo | |
### 10.5 model 定义
- 文件:`models/model.py`
- 类名:
- 输入:
- 输出:
- 关键模块:
- 论文依据:
- 缺失/假设:
### 10.6 train 定义
- 文件:`engine/train.py`
- loss / objective:
- optimizer:
- scheduler:
- checkpoint:
- logging:
- 论文依据:
- 缺失/假设:
### 10.7 evaluate 定义
- 文件:`engine/evaluate.py`
- 指标:
- protocol:
- checkpoint:
- 输出文件:
- 论文依据:
- 缺失/假设:
### 10.8 数据处理实现
| 数据集 | 数据处理文件 | 入口命令 | 来源证据 | 风险 |
|---|---|---|---|---|
| | `data/preprocess.py` / `data/dataset.py` | `python main.py --mode preprocess ...` | | |
### 10.9 可执行命令
```cmd
python -m pip install -r repro-docs/requirements.txt
python main.py --mode preprocess --dataset <dataset> --data_root <path>
python main.py --mode train --dataset <dataset> --data_root <path>
python main.py --mode eval --checkpoint outputs/best.pt
```
### 10.10 未完成项 / 人工确认项
- 未下载数据:
- 未安装依赖:
- 论文缺失:
- 需要确认的假设:
## 11. 不能复现或不能精确复现的原因
- 原因 1:
- 原因 2:
- 原因 3:
## 12. 执行日志
| 时间 | 命令 / 工具 | 结果 | 备注 |
|---|---|---|---|
| | | | |
# 聊天极简摘要模板
```markdown
[paper-repro-triage active]
- 报告文件:`paper-repro-workspace/<paper-slug>/repro-report.md`
- 主论文源码:已 clone / 已存在,跳过 clone / 本地已存在 / 未找到 / 等待审批 / clone 失败
- 数据集源码:已 clone N 个 / 已存在,跳过 clone N 个 / 本地已存在 N 个 / 未找到 / 部分找到 / 未检索
- 数据处理代码:已定位 N 处 / 未定位 / 不适用
- 复现工程:已生成 / 仅生成 skeleton / 未生成,路径:`paper-repro-workspace/<paper-slug>/<implementation-slug>/`
- 是否需要复现:需要 / 不需要 / 建议只做部分复现
- 是否能复现:可以直接复现 / 部分可复现 / 不具备实际可复现性 / 不是复现目标
- 核心原因:一句话说明;如果能复现则写“无核心阻碍”
```
FILE:references/reproducibility-rubric.md
# 可复现性判定标准
只能使用以下四个结论之一:
1. 可以直接复现
2. 部分可复现
3. 不具备实际可复现性
4. 不是复现目标
## 可以直接复现
必须满足大部分条件:
- 有作者官方或高度可信代码仓库,或论文信息足以生成一个与论文方法一致的最小可运行实现。
- 数据集可公开获取,或论文提供明确申请流程。
- 训练和评测脚本、协议或足够清晰的流程可获得。
- 关键超参数、模型配置、预处理和指标足够明确。
- 不依赖不可获得的私有模型、私有数据或闭源 API。
- 硬件需求在用户可接受范围内,或有小规模可验证路径。
## 部分可复现
符合以下情况之一:
- 有代码,但数据集需要申请、部分缺失或预处理不完整。
- 有数据,但代码不完整或缺少训练脚本。
- 无官方源码,但核心方法、loss、数据管线和评测指标足以构造最小可行实现。
- 核心方法可实现,但超参数、消融或评测细节不足。
- 依赖大规模算力,但可以先复现缩小版或核心模块。
- prompt/agent 流程可以复现思路,但无法精确复现闭源模型行为。
## 不具备实际可复现性
符合以下情况之一:
- 无源码、无关键训练细节、无可得数据,且无法合理构造最小可行实现。
- 依赖私有数据、私有权重、内部日志或不可访问系统。
- 依赖闭源 API 的不可控行为,且 prompt、温度、版本、工具链缺失。
- 实验协议和指标定义不清,无法构造可靠对照。
- 所需硬件、数据规模或人工标注流程远超普通复现能力,且没有缩小版路径。
## 不是复现目标
符合以下情况之一:
- 综述、评论、观点文章或 survey。
- 主要贡献是概念框架或理论分析,没有可执行方法。
- 资源论文仅介绍数据集或平台,而用户目标不是复现数据构建过程。
- benchmark 论文只定义评测任务,用户更应使用其 benchmark,而不是“复现论文方法”。
## 是否需要复现的独立判断
“能不能复现”和“需不需要复现”分开判断:
- 如果论文是目标任务强相关方法论文,且代码/数据足够,通常“需要复现”。
- 如果只是综述或背景材料,通常“不需要复现”。
- 如果代码存在但算力或数据受限,通常“建议只做部分复现”。
- 如果没有代码但方法清楚,通常“建议生成最小复现工程,再由用户确认是否安装依赖/下载数据/训练”。
- 如果是数据集论文,通常优先复现数据加载、预处理和 benchmark 使用流程,而不是重新构建整个数据集。
FILE:agents/openai.yaml
interface:
display_name: "论文复现执行器"
short_description: "分析论文、检索数据集论文源码、自动克隆仓库并写入 Markdown 报告"
structured academic paper analysis from local paper files or paper urls, adapted from a dify scheme a workflow. use when the user asks to analyze pdf/docx/te...
---
name: paper-analysis-evidence
description: structured academic paper analysis from local paper files or paper urls, adapted from a dify scheme a workflow. use when the user asks to analyze pdf/docx/text/html academic papers, extract title/task/background/problem/method/datasets/baselines/metrics/results/ablations/limitations/contributions, cite evidence spans, verify consistency against the original paper, or export paper analysis reports. supports chinese or english outputs and saves downloaded inputs, intermediate files, generated json, markdown, html, and docx reports under the ubuntu desktop.
---
# Paper Analysis Evidence
## Purpose
Run the Scheme A evidence-enhanced paper analysis workflow: prepare paper inputs, split the paper into key sections, generate structured extraction JSON, verify the extraction against the original text, and render final reports.
This skill is based on the uploaded Dify workflow `论文分析系统_方案A_结构化证据增强版`.
## Runtime file policy
Always save runtime downloads and generated outputs under the Ubuntu desktop unless the user explicitly requests another location:
```bash
~/Desktop/paper_analysis_results/<YYYYMMDD_HHMMSS>/
```
Do not modify the original local paper file. Copy it into the work directory before extraction. Download URL inputs into the same batch work directory.
## Inputs
Accept:
- `language`: `中文` or `英文`; default to `中文` when unspecified.
- `paper_files`: one or more local paper files, preferably PDF, DOCX, TXT, MD, or HTML.
- `paper_urls`: one or more PDF/direct paper URLs, comma-separated or repeated.
If both local files and URLs are empty, stop with this message:
```text
上传的文件和论文URL不能同时为空。
```
## Workflow
### 1. Prepare inputs and sections
Run:
```bash
python scripts/prepare_papers.py --language 中文 --files /path/to/paper.pdf --urls "https://example.com/paper.pdf"
```
Use only the relevant arguments. For URL-only runs, omit `--files`; for local-only runs, omit `--urls`.
The script creates `manifest.json` and one work directory per paper. It performs:
1. local file copy or URL download,
2. raw text extraction,
3. text cleaning,
4. section splitting into abstract, intro, method, experiment, conclusion, and `paper_body`,
5. prompt file generation.
### 2. Generate structured extraction JSON
For each paper in `manifest.json`, read:
```text
prompts/01_structured_extraction_prompt.md
```
Send that prompt to the model. Save the model response exactly as JSON-only content to:
```text
generated/structured_result.json
```
Required JSON fields:
```json
{
"title": "",
"task": "",
"background": "",
"problem_statement": "",
"method_name": "",
"method_core": "",
"datasets": [],
"baselines": [],
"metrics": [],
"main_results": [
{"dataset": "", "metric": "", "value": "", "baseline": "", "improvement": ""}
],
"ablations": [],
"limitations": [],
"claims": [],
"contributions": [],
"evidence_spans": [
{"field": "", "claim": "", "evidence": ""}
]
}
```
Extraction rules:
- Only use information present in, or directly inferable from, the paper.
- Prefer corresponding sections, but fall back to the full `paper_body` when a section is empty or insufficient.
- Do not leave `datasets`, `baselines`, or `metrics` empty just because the experiment section is weak; first check `paper_body`, result text, implementation details, and table-neighboring text.
- Use empty strings or arrays only when the full paper text truly lacks the information.
- Provide at least 6 evidence spans. Each evidence span must be a direct quote or a very close paraphrase from the source text.
- Prioritize numeric results from experiment, results, analysis, implementation details, or table-neighboring text.
- Keep JSON keys in English. Natural-language values must use the selected output language.
### 3. Run consistency verification
Open:
```text
prompts/02_verification_prompt_template.md
```
Replace `{{structured_json}}` with the actual content of `generated/structured_result.json`. Send the complete verification prompt to the model and save JSON-only output to:
```text
generated/verification_result.json
```
Required verification JSON:
```json
{
"overall_score": 0,
"hallucination_risk": "low/medium/high",
"issues": [
{"field": "", "problem": "", "severity": "low/medium/high"}
],
"verified_claims": [
{"claim": "", "status": "supported/weak/unsupported", "evidence": ""}
],
"final_verdict": ""
}
```
Verification rules:
- Score 5: nearly no hallucination, strong evidence.
- Score 4: minor imprecision.
- Score 3: several claims lack evidence.
- Score 2: clear inconsistency exists.
- Score 1: substantial hallucination or misreading.
- Focus on omitted or incorrect datasets, baselines, metrics, and main results.
- If the structured extraction uses an empty array/string for information that exists in the original paper, explicitly list that in `issues`.
- Provide at least 4 verified claims.
### 4. Render reports
After `structured_result.json` and `verification_result.json` are saved for every paper, run:
```bash
python scripts/render_report.py --manifest ~/Desktop/paper_analysis_results/<YYYYMMDD_HHMMSS>/manifest.json
```
Outputs per paper:
```text
report/final_report.md
report/final_report.html
report/final_report.docx
```
The `.md` file preserves editable Markdown source. The `.html` file is the rendered visual version. The `.docx` file is the Word-compatible report.
## Report structure
Chinese report sections:
1. 论文题目
2. 任务与问题
3. 方法概述
4. 实验要素:数据集、基线方法、评价指标
5. 主要结果
6. 贡献提炼
7. 消融与局限性
8. 证据片段
9. 一致性校验:总评分、幻觉风险、最终结论、已核验结论、发现的问题
English report sections mirror the same structure as `Paper Analysis`.
## References
- Use `references/prompt_templates.md` when prompt details are needed.
- Use `references/workflow_mapping.md` when checking how the Dify nodes map to this skill.
- `references/dify_scheme_a_source.yml` preserves the uploaded Dify DSL source for auditability.
FILE:scripts/prepare_papers.py
#!/usr/bin/env python3
"""Prepare paper inputs for the evidence-enhanced paper analysis workflow.
This script handles the deterministic parts of the Dify Scheme A workflow:
- validate that at least one local file or URL is provided
- copy local files and download URL inputs into a desktop work directory
- extract text from common document formats
- clean text
- split text into paper sections
- write prompt-ready files for LLM structured extraction and verification
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable
MIN_TEXT_LENGTH = 800
def desktop_root() -> Path:
return Path.home() / "Desktop" / "paper_analysis_results"
def timestamp() -> str:
return time.strftime("%Y%m%d_%H%M%S")
def sanitize_filename(name: str, fallback: str = "paper") -> str:
name = urllib.parse.unquote(name or "")
name = name.strip().replace("\\", "/").split("/")[-1]
name = re.sub(r"[^A-Za-z0-9._\-\u4e00-\u9fff]+", "_", name).strip("._")
return name or fallback
def split_csv(values: Iterable[str] | None) -> list[str]:
result: list[str] = []
for value in values or []:
for item in (value or "").split(","):
item = item.strip()
if item:
result.append(item)
return result
def safe_copy_file(src: Path, dest_dir: Path) -> Path:
if not src.exists() or not src.is_file():
raise FileNotFoundError(f"local paper file not found: {src}")
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / sanitize_filename(src.name)
if dest.exists():
stem, suffix = dest.stem, dest.suffix
i = 2
while True:
candidate = dest_dir / f"{stem}_{i}{suffix}"
if not candidate.exists():
dest = candidate
break
i += 1
shutil.copy2(src, dest)
return dest
def guess_download_name(url: str, content_type: str | None = None) -> str:
path_name = sanitize_filename(urllib.parse.urlparse(url).path, "paper")
if "." in path_name:
return path_name
if content_type:
ct = content_type.lower()
if "pdf" in ct:
return path_name + ".pdf"
if "word" in ct or "docx" in ct:
return path_name + ".docx"
if "html" in ct:
return path_name + ".html"
if "text" in ct:
return path_name + ".txt"
return path_name + ".pdf"
def download_url(url: str, dest_dir: Path, timeout: int = 60, max_retries: int = 2) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 paper-analysis-evidence"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
content_type = resp.headers.get("Content-Type")
name = guess_download_name(url, content_type)
dest = dest_dir / name
if dest.exists():
stem, suffix = dest.stem, dest.suffix
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()[:8]
dest = dest_dir / f"{stem}_{digest}{suffix}"
with open(dest, "wb") as f:
shutil.copyfileobj(resp, f)
return dest
except Exception as exc: # pragma: no cover - depends on network
last_error = exc
if attempt < max_retries:
time.sleep(1.5 * (attempt + 1))
raise RuntimeError(f"failed to download {url}: {last_error}")
def extract_pdf_text(path: Path) -> str:
errors: list[str] = []
for module_name in ("pypdf", "PyPDF2"):
try:
module = __import__(module_name)
reader = module.PdfReader(str(path))
pages = []
for page in reader.pages:
pages.append(page.extract_text() or "")
text = "\n\n".join(pages).strip()
if text:
return text
except Exception as exc:
errors.append(f"{module_name}: {exc}")
try:
completed = subprocess.run(
["pdftotext", str(path), "-"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=120,
)
text = completed.stdout.strip()
if text:
return text
except Exception as exc:
errors.append(f"pdftotext: {exc}")
raise RuntimeError("could not extract PDF text. install pypdf or poppler-utils. " + "; ".join(errors))
def extract_docx_text(path: Path) -> str:
with zipfile.ZipFile(path) as zf:
xml = zf.read("word/document.xml").decode("utf-8", errors="ignore")
xml = re.sub(r"</w:p>", "\n", xml)
xml = re.sub(r"<[^>]+>", "", xml)
entities = {"&": "&", "<": "<", ">": ">", """: '"', "'": "'"}
for k, v in entities.items():
xml = xml.replace(k, v)
return xml.strip()
def extract_html_text(path: Path) -> str:
text = path.read_text(encoding="utf-8", errors="ignore")
text = re.sub(r"(?is)<script[^>]*>.*?</script>", " ", text)
text = re.sub(r"(?is)<style[^>]*>.*?</style>", " ", text)
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = re.sub(r"(?i)</p>", "\n", text)
text = re.sub(r"<[^>]+>", " ", text)
text = text.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">")
return text.strip()
def extract_text(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".pdf":
return extract_pdf_text(path)
if suffix == ".docx":
return extract_docx_text(path)
if suffix in {".txt", ".md", ".markdown"}:
return path.read_text(encoding="utf-8", errors="ignore")
if suffix in {".html", ".htm"}:
return extract_html_text(path)
try:
return path.read_text(encoding="utf-8", errors="ignore")
except Exception as exc:
raise RuntimeError(f"unsupported document format: {path.name}. Use PDF, DOCX, TXT, MD, or HTML. {exc}")
def clean_for_analysis(raw_text: str, min_length: int = MIN_TEXT_LENGTH) -> tuple[str, int, str]:
if not raw_text or not isinstance(raw_text, str):
return "", 0, "input text is empty or invalid"
text = raw_text.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\nReferences[\s\S]*$", "", text, flags=re.I)
text = re.sub(r"\nBibliography[\s\S]*$", "", text, flags=re.I)
text = text.strip()
word_count = len(text)
if word_count < min_length:
return text, word_count, f"text is short ({word_count} chars); extraction may have failed"
return text, word_count, ""
def normalize_newlines(text: str) -> str:
text = (text or "").replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def trim_references(text: str) -> str:
if not text:
return ""
patterns = [r"\n\s*(references|bibliography)\s*$", r"\n\s*参考文献\s*$"]
cut_positions = []
for p in patterns:
m = re.search(p, text, flags=re.I | re.M)
if m:
cut_positions.append(m.start())
if cut_positions:
text = text[:min(cut_positions)]
return text.strip()
def heading_patterns() -> dict[str, list[str]]:
return {
"abstract": [r"abstract", r"摘要"],
"intro": [r"introduction", r"intro", r"引言", r"绪论"],
"related": [r"related work", r"background", r"preliminar(?:y|ies)", r"相关工作", r"背景", r"预备知识"],
"method": [
r"method", r"methods", r"methodology", r"approach", r"approaches", r"framework", r"model", r"architecture",
r"proposed method", r"proposed approach", r"our method", r"our approach", r"方法", r"模型", r"框架", r"架构",
],
"experiment": [
r"experiment", r"experiments", r"experimental setup", r"evaluation", r"evaluations", r"results", r"analysis",
r"implementation details", r"empirical study", r"实验", r"实验设置", r"实验结果", r"评估", r"结果", r"分析", r"实现细节",
],
"conclusion": [r"conclusion", r"conclusions", r"discussion", r"limitations", r"future work", r"结论", r"讨论", r"局限性", r"未来工作"],
"tail": [r"acknowledg(?:e)?ments?", r"appendix", r"references", r"bibliography", r"致谢", r"附录", r"参考文献"],
}
def line_is_heading(line: str) -> bool:
s = line.strip()
return bool(s) and len(s) <= 120 and s.count(". ") < 2
def detect_heading_type(line: str, patterns: dict[str, list[str]]) -> str | None:
s = line.strip()
if not line_is_heading(s):
return None
normalized = re.sub(
r"^\s*(section\s+)?((\d+(\.\d+)*)|[ivxlcdm]+|第\s*\d+\s*[章节部分])[\.\:\-\s]*",
"",
s,
flags=re.I,
).strip()
for sec_type, pats in patterns.items():
for pat in pats:
if re.fullmatch(rf"{pat}", normalized, flags=re.I):
return sec_type
for sec_type, pats in patterns.items():
for pat in pats:
if re.search(rf"\b{pat}\b", normalized, flags=re.I):
return sec_type
return None
def collect_headings(text: str) -> list[dict[str, object]]:
patterns = heading_patterns()
headings: list[dict[str, object]] = []
offset = 0
for line in text.split("\n"):
sec_type = detect_heading_type(line, patterns)
if sec_type:
headings.append({"type": sec_type, "title": line.strip(), "start": offset})
offset += len(line) + 1
headings.sort(key=lambda x: int(x["start"]))
return headings
def get_section_by_heading(text: str, headings: list[dict[str, object]], target_type: str) -> str:
candidates = [h for h in headings if h["type"] == target_type]
if not candidates:
return ""
start = int(candidates[0]["start"])
later = [int(h["start"]) for h in headings if int(h["start"]) > start]
end = min(later) if later else len(text)
return text[start:end].strip()
def fallback_window(text: str, keywords: list[str], max_len: int = 10000, search_start: int = 0) -> str:
window_text = text[search_start:]
best: int | None = None
for kw in keywords:
m = re.search(kw, window_text, flags=re.I)
if m:
pos = search_start + m.start()
if best is None or pos < best:
best = pos
if best is None:
return ""
start = max(0, best - 150)
end = min(len(text), start + max_len)
return text[start:end].strip()
def normalize_section(section: str, max_len: int) -> str:
if not section:
return ""
section = section.strip()
section = re.sub(r"\n{3,}", "\n\n", section)
return section[:max_len]
def split_sections(cleaned_text: str) -> dict[str, str]:
text = trim_references(normalize_newlines(cleaned_text))
if not text:
return {
"abstract_section": "", "intro_section": "", "method_section": "",
"experiment_section": "", "conclusion_section": "", "paper_body": "",
}
headings = collect_headings(text)
abstract = get_section_by_heading(text, headings, "abstract")
if not abstract:
m = re.search(r"(^|\n)\s*(abstract|摘要)\s*\n", text, flags=re.I)
if m:
start = m.start()
next_heads = [int(h["start"]) for h in headings if int(h["start"]) > start]
end = min(next_heads) if next_heads else min(len(text), start + 6000)
abstract = text[start:end].strip()
else:
intro_heads = [int(h["start"]) for h in headings if h["type"] == "intro"]
if intro_heads and intro_heads[0] > 200:
abstract = text[:intro_heads[0]].strip()[:6000]
intro = get_section_by_heading(text, headings, "intro")
if not intro:
m = re.search(r"(introduction|引言|绪论)", text[:max(3000, len(text)//5)], flags=re.I)
if m:
start = max(0, m.start() - 100)
next_heads = [int(h["start"]) for h in headings if int(h["start"]) > start]
end = min(next_heads) if next_heads else min(len(text), start + 8000)
intro = text[start:end].strip()
method = get_section_by_heading(text, headings, "method")
if not method:
intro_heads = [int(h["start"]) for h in headings if h["type"] == "intro"]
method = fallback_window(text, [r"\bmethod\b", r"\bmethods\b", r"\bmethodology\b", r"\bapproach\b", r"\bframework\b", r"\barchitecture\b", r"\bmodel\b", r"方法", r"框架", r"架构", r"模型"], 12000, intro_heads[0] if intro_heads else 0)
experiment = get_section_by_heading(text, headings, "experiment")
if not experiment:
method_heads = [int(h["start"]) for h in headings if h["type"] == "method"]
experiment = fallback_window(text, [r"\bexperiments?\b", r"\bevaluation\b", r"\bresults\b", r"\banalysis\b", r"\bimplementation details\b", r"实验", r"评估", r"结果", r"分析", r"实现细节"], 12000, method_heads[0] if method_heads else 0)
conclusion = get_section_by_heading(text, headings, "conclusion")
if not conclusion:
conclusion = fallback_window(text, [r"\bconclusion\b", r"\bconclusions\b", r"\bdiscussion\b", r"\blimitations\b", r"\bfuture work\b", r"结论", r"讨论", r"局限", r"未来工作"], 6000, len(text)//2)
return {
"abstract_section": normalize_section(abstract, 6000),
"intro_section": normalize_section(intro, 8000),
"method_section": normalize_section(method, 12000),
"experiment_section": normalize_section(experiment, 12000),
"conclusion_section": normalize_section(conclusion, 6000),
"paper_body": text[:60000],
}
STRUCTURED_PROMPT_TEMPLATE = """你是一位严谨的论文信息抽取器。请严格基于给定论文内容,输出一个 JSON 对象,不要输出 Markdown、不要解释、不要加 ```json。
输出字段必须包含:
{{
"title": "",
"task": "",
"background": "",
"problem_statement": "",
"method_name": "",
"method_core": "",
"datasets": [],
"baselines": [],
"metrics": [],
"main_results": [
{{"dataset": "", "metric": "", "value": "", "baseline": "", "improvement": ""}}
],
"ablations": [],
"limitations": [],
"claims": [],
"contributions": [],
"evidence_spans": [
{{"field": "", "claim": "", "evidence": ""}}
]
}}
规则:
1. 只能写原文出现或可直接推出的信息,不要脑补。
2. 优先使用对应章节抽取信息;如果某章节为空或信息不足,必须从全文补充。
3. datasets、baselines、metrics 不允许因为章节为空而直接写空数组,必须先检查全文 paper_body、实验结果、实现细节、表格附近文本是否存在相关信息。
4. 只有在全文中也确实找不到时,才能写空字符串或空数组。
5. evidence_spans 至少给 6 条,每条都必须是原文中的直接证据片段或非常贴近原文的概括。
6. 数值结果优先来自实验部分、结果部分、分析部分或表格附近文本。
7. JSON 键名保持英文;JSON 中的自然语言内容使用 {language}。
论文摘要段:
{abstract_section}
引言段:
{intro_section}
方法段:
{method_section}
实验段:
{experiment_section}
结论段:
{conclusion_section}
全文:
{paper_body}
"""
VERIFICATION_PROMPT_TEMPLATE = """你是一位论文事实核验器。请对下面的结构化抽取结果做一致性校验,并输出 JSON 对象,不要输出 Markdown、不要解释。
输出格式:
{{
"overall_score": 0,
"hallucination_risk": "low/medium/high",
"issues": [
{{"field": "", "problem": "", "severity": "low/medium/high"}}
],
"verified_claims": [
{{"claim": "", "status": "supported/weak/unsupported", "evidence": ""}}
],
"final_verdict": ""
}}
评分规则:
- 5:几乎无幻觉,证据充分
- 4:少量不严谨
- 3:有若干缺证据表述
- 2:存在明显不一致
- 1:大量幻觉或错读
要求:
1. JSON 键名保持英文。
2. JSON 中的自然语言内容使用 {language}。
3. 重点检查 datasets、baselines、metrics、main_results 是否遗漏或误抽。
4. 若抽取结果将原文存在的信息写成空数组或空字符串,请在 issues 中明确指出。
5. verified_claims 至少给 4 条。
原文:
{paper_body}
待校验JSON:
{{structured_json}}
"""
@dataclass
class PaperRecord:
id: str
source_type: str
source: str
input_file: str
work_dir: str
raw_text_file: str
cleaned_text_file: str
sections_file: str
structured_prompt_file: str
verification_prompt_file: str
structured_json_file: str
verification_json_file: str
final_markdown_file: str
final_html_file: str
final_docx_file: str
language: str
word_count: int
warning: str = ""
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def prepare_one(input_path: Path, source_type: str, source: str, batch_dir: Path, language: str, index: int) -> PaperRecord:
paper_id = f"paper_{index:02d}_{sanitize_filename(input_path.stem, 'paper')}"
paper_dir = batch_dir / paper_id
for sub in ["input", "text", "sections", "prompts", "generated", "report"]:
(paper_dir / sub).mkdir(parents=True, exist_ok=True)
kept = safe_copy_file(input_path, paper_dir / "input") if input_path.parent != paper_dir / "input" else input_path
raw = extract_text(kept)
cleaned, word_count, warning = clean_for_analysis(raw)
sections = split_sections(cleaned)
raw_file = paper_dir / "text" / "raw_text.txt"
cleaned_file = paper_dir / "text" / "cleaned_text.txt"
sections_file = paper_dir / "sections" / "sections.json"
structured_prompt = paper_dir / "prompts" / "01_structured_extraction_prompt.md"
verification_prompt = paper_dir / "prompts" / "02_verification_prompt_template.md"
structured_json = paper_dir / "generated" / "structured_result.json"
verification_json = paper_dir / "generated" / "verification_result.json"
final_md = paper_dir / "report" / "final_report.md"
final_html = paper_dir / "report" / "final_report.html"
final_docx = paper_dir / "report" / "final_report.docx"
write_text(raw_file, raw)
write_text(cleaned_file, cleaned)
sections_file.write_text(json.dumps(sections, ensure_ascii=False, indent=2), encoding="utf-8")
write_text(structured_prompt, STRUCTURED_PROMPT_TEMPLATE.format(language=language, **sections))
write_text(verification_prompt, VERIFICATION_PROMPT_TEMPLATE.format(language=language, paper_body=sections["paper_body"]))
if not structured_json.exists():
write_text(structured_json, "{}\n")
if not verification_json.exists():
write_text(verification_json, "{}\n")
return PaperRecord(
id=paper_id,
source_type=source_type,
source=source,
input_file=str(kept),
work_dir=str(paper_dir),
raw_text_file=str(raw_file),
cleaned_text_file=str(cleaned_file),
sections_file=str(sections_file),
structured_prompt_file=str(structured_prompt),
verification_prompt_file=str(verification_prompt),
structured_json_file=str(structured_json),
verification_json_file=str(verification_json),
final_markdown_file=str(final_md),
final_html_file=str(final_html),
final_docx_file=str(final_docx),
language=language,
word_count=word_count,
warning=warning,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Prepare paper files for evidence-enhanced analysis.")
parser.add_argument("--language", default="中文", choices=["中文", "英文"], help="Natural language for generated content.")
parser.add_argument("--files", nargs="*", default=[], help="Local paper files. PDF, DOCX, TXT, MD, HTML are supported.")
parser.add_argument("--urls", nargs="*", default=[], help="Paper PDF/direct URLs. Multiple comma-separated values are also accepted.")
parser.add_argument("--output-root", default=str(desktop_root()), help="Root output directory. Defaults to ~/Desktop/paper_analysis_results.")
args = parser.parse_args()
files = [Path(p).expanduser().resolve() for p in args.files if p]
urls = split_csv(args.urls)
if not files and not urls:
print("上传的文件和论文URL不能同时为空。", file=sys.stderr)
return 2
batch_dir = Path(args.output_root).expanduser() / timestamp()
downloads_dir = batch_dir / "downloads"
batch_dir.mkdir(parents=True, exist_ok=True)
records: list[PaperRecord] = []
index = 1
for file_path in files:
records.append(prepare_one(file_path, "local_file", str(file_path), batch_dir, args.language, index))
index += 1
for url in urls:
downloaded = download_url(url, downloads_dir)
records.append(prepare_one(downloaded, "url", url, batch_dir, args.language, index))
index += 1
manifest = {
"workflow": "paper-analysis-evidence",
"language": args.language,
"created_at": timestamp(),
"batch_dir": str(batch_dir),
"papers": [asdict(r) for r in records],
"next_steps": [
"For each paper, send prompts/01_structured_extraction_prompt.md to the model and save the JSON-only answer to generated/structured_result.json.",
"Then send prompts/02_verification_prompt_template.md plus the structured JSON to the model and save the JSON-only answer to generated/verification_result.json.",
"Run scripts/render_report.py with the manifest to create final_report.md, final_report.html, and final_report.docx.",
],
}
manifest_path = batch_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"manifest": str(manifest_path), "batch_dir": str(batch_dir), "papers": [r.id for r in records]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:scripts/render_report.py
#!/usr/bin/env python3
"""Render final reports for the evidence-enhanced paper analysis workflow.
Given structured extraction JSON and verification JSON, create Markdown, HTML,
and DOCX reports. This mirrors the Dify Scheme A aggregation node while adding
local file outputs that are easy to inspect on Ubuntu Desktop.
"""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import sys
import zipfile
from pathlib import Path
from typing import Any
def parse_json_text(text: str) -> dict[str, Any]:
if not text:
return {}
s = text.strip()
s = re.sub(r"^```json\s*", "", s)
s = re.sub(r"^```", "", s)
s = re.sub(r"```$", "", s)
s = s.strip()
try:
data = json.loads(s)
return data if isinstance(data, dict) else {"raw_text": data}
except Exception:
match = re.search(r"\{[\s\S]*\}", s)
if match:
try:
data = json.loads(match.group(0))
return data if isinstance(data, dict) else {"raw_text": data}
except Exception:
return {"raw_text": s}
return {"raw_text": s}
def is_english(language: str) -> bool:
return (language or "").strip() == "英文"
def empty_text(language: str) -> str:
return "None" if is_english(language) else "无"
def clean_item(x: Any) -> str:
if isinstance(x, (dict, list)):
return json.dumps(x, ensure_ascii=False)
return str(x).strip()
def list_md(items: Any, language: str) -> str:
fallback = empty_text(language)
if not items:
return fallback
if isinstance(items, list):
lines = [f"- {clean_item(x)}" for x in items if clean_item(x)]
return "\n".join(lines) if lines else fallback
s = clean_item(items)
return s if s else fallback
def results_md(items: Any, language: str) -> str:
fallback = empty_text(language)
if not items or not isinstance(items, list):
return fallback
lines = []
en = is_english(language)
for it in items:
if isinstance(it, dict):
if en:
lines.append(f"- Dataset: {it.get('dataset','')}; Metric: {it.get('metric','')}; Value: {it.get('value','')}; Baseline: {it.get('baseline','')}; Improvement: {it.get('improvement','')}")
else:
lines.append(f"- 数据集:{it.get('dataset','')};指标:{it.get('metric','')};结果:{it.get('value','')};对比基线:{it.get('baseline','')};提升:{it.get('improvement','')}")
else:
s = clean_item(it)
if s:
lines.append(f"- {s}")
return "\n".join(lines) if lines else fallback
def evidence_md(items: Any, language: str) -> str:
fallback = empty_text(language)
if not items or not isinstance(items, list):
return fallback
en = is_english(language)
lines = []
for it in items[:8]:
if isinstance(it, dict):
if en:
lines.append(f"- Field: {it.get('field','')} | Claim: {it.get('claim','')} | Evidence: {it.get('evidence','')}")
else:
lines.append(f"- 字段:{it.get('field','')}|结论:{it.get('claim','')}|证据:{it.get('evidence','')}")
else:
s = clean_item(it)
if s:
lines.append(f"- {s}")
return "\n".join(lines) if lines else fallback
def verify_md(items: Any, language: str) -> str:
fallback = empty_text(language)
if not items or not isinstance(items, list):
return fallback
en = is_english(language)
lines = []
for it in items:
if isinstance(it, dict):
if en:
lines.append(f"- Claim: {it.get('claim','')} | Status: {it.get('status','')} | Evidence: {it.get('evidence','')}")
else:
lines.append(f"- {it.get('claim','')}|状态:{it.get('status','')}|证据:{it.get('evidence','')}")
else:
s = clean_item(it)
if s:
lines.append(f"- {s}")
return "\n".join(lines) if lines else fallback
def issues_md(items: Any, language: str) -> str:
fallback = empty_text(language)
if not items or not isinstance(items, list):
return fallback
en = is_english(language)
lines = []
for it in items:
if isinstance(it, dict):
if en:
lines.append(f"- Field: {it.get('field','')} | Problem: {it.get('problem','')} | Severity: {it.get('severity','')}")
else:
lines.append(f"- 字段:{it.get('field','')}|问题:{it.get('problem','')}|严重度:{it.get('severity','')}")
else:
s = clean_item(it)
if s:
lines.append(f"- {s}")
return "\n".join(lines) if lines else fallback
def build_markdown(structured_json: str, verification_json: str, language: str) -> str:
data = parse_json_text(structured_json)
verify = parse_json_text(verification_json)
en = is_english(language)
title = data.get("title", "Untitled" if en else "未识别题目")
if en:
return f"""# Paper Analysis
## Title
{title}
## Task and Problem
- Task: {data.get('task', '')}
- Background: {data.get('background', '')}
- Problem Statement: {data.get('problem_statement', '')}
## Method Overview
- Method Name: {data.get('method_name', '')}
- Method Core: {data.get('method_core', '')}
## Experimental Elements
### Datasets
{list_md(data.get('datasets', []), language)}
### Baselines
{list_md(data.get('baselines', []), language)}
### Metrics
{list_md(data.get('metrics', []), language)}
## Main Results
{results_md(data.get('main_results', []), language)}
## Contributions
{list_md(data.get('contributions', []), language)}
## Ablations and Limitations
### Ablations
{list_md(data.get('ablations', []), language)}
### Limitations
{list_md(data.get('limitations', []), language)}
## Evidence Spans
{evidence_md(data.get('evidence_spans', []), language)}
## Consistency Check
- Overall Score: {verify.get('overall_score', '')}
- Hallucination Risk: {verify.get('hallucination_risk', '')}
- Final Verdict: {verify.get('final_verdict', '')}
### Verified Claims
{verify_md(verify.get('verified_claims', []), language)}
### Issues
{issues_md(verify.get('issues', []), language)}
"""
return f"""# 论文分析结果
## 论文题目
{title}
## 任务与问题
- 研究任务:{data.get('task', '')}
- 背景:{data.get('background', '')}
- 问题定义:{data.get('problem_statement', '')}
## 方法概述
- 方法名称:{data.get('method_name', '')}
- 方法核心:{data.get('method_core', '')}
## 实验要素
### 数据集
{list_md(data.get('datasets', []), language)}
### 基线方法
{list_md(data.get('baselines', []), language)}
### 评价指标
{list_md(data.get('metrics', []), language)}
## 主要结果
{results_md(data.get('main_results', []), language)}
## 贡献提炼
{list_md(data.get('contributions', []), language)}
## 消融与局限性
### 消融实验
{list_md(data.get('ablations', []), language)}
### 局限性
{list_md(data.get('limitations', []), language)}
## 证据片段
{evidence_md(data.get('evidence_spans', []), language)}
## 一致性校验
- 总评分:{verify.get('overall_score', '')}
- 幻觉风险:{verify.get('hallucination_risk', '')}
- 最终结论:{verify.get('final_verdict', '')}
### 已核验结论
{verify_md(verify.get('verified_claims', []), language)}
### 发现的问题
{issues_md(verify.get('issues', []), language)}
"""
def markdown_to_html(markdown: str, title: str = "Paper Analysis") -> str:
try:
import markdown as markdown_lib # type: ignore
body = markdown_lib.markdown(markdown, extensions=["tables", "fenced_code", "sane_lists"])
except Exception:
body_lines: list[str] = []
in_ul = False
for raw in markdown.splitlines():
line = raw.rstrip()
if line.startswith("### "):
if in_ul:
body_lines.append("</ul>"); in_ul = False
body_lines.append(f"<h3>{html.escape(line[4:])}</h3>")
elif line.startswith("## "):
if in_ul:
body_lines.append("</ul>"); in_ul = False
body_lines.append(f"<h2>{html.escape(line[3:])}</h2>")
elif line.startswith("# "):
if in_ul:
body_lines.append("</ul>"); in_ul = False
body_lines.append(f"<h1>{html.escape(line[2:])}</h1>")
elif line.startswith("- "):
if not in_ul:
body_lines.append("<ul>"); in_ul = True
body_lines.append(f"<li>{html.escape(line[2:])}</li>")
elif not line.strip():
if in_ul:
body_lines.append("</ul>"); in_ul = False
else:
if in_ul:
body_lines.append("</ul>"); in_ul = False
body_lines.append(f"<p>{html.escape(line)}</p>")
if in_ul:
body_lines.append("</ul>")
body = "\n".join(body_lines)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{html.escape(title)}</title>
<style>
body {{ max-width: 920px; margin: 40px auto; padding: 0 24px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.65; color: #1f2937; }}
h1, h2, h3 {{ color: #111827; line-height: 1.25; }}
h1 {{ border-bottom: 2px solid #e5e7eb; padding-bottom: 12px; }}
h2 {{ margin-top: 32px; border-bottom: 1px solid #e5e7eb; padding-bottom: 6px; }}
li {{ margin: 6px 0; }}
code, pre {{ background: #f3f4f6; }}
</style>
</head>
<body>
{body}
</body>
</html>
"""
def write_docx_with_python_docx(markdown: str, output: Path) -> bool:
try:
from docx import Document # type: ignore
except Exception:
return False
doc = Document()
for raw in markdown.splitlines():
line = raw.rstrip()
if not line:
continue
if line.startswith("# "):
doc.add_heading(line[2:].strip(), level=1)
elif line.startswith("## "):
doc.add_heading(line[3:].strip(), level=2)
elif line.startswith("### "):
doc.add_heading(line[4:].strip(), level=3)
elif line.startswith("- "):
doc.add_paragraph(line[2:].strip(), style="List Bullet")
else:
doc.add_paragraph(line)
doc.save(output)
return True
def write_minimal_docx(markdown: str, output: Path) -> None:
def xml_escape(s: str) -> str:
return html.escape(s, quote=False)
paras = []
for raw in markdown.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#"):
line = line.lstrip("#").strip()
elif line.startswith("- "):
line = "• " + line[2:].strip()
paras.append(f"<w:p><w:r><w:t>{xml_escape(line)}</w:t></w:r></w:p>")
document_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>%s<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>""" % "".join(paras)
content_types = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"""
rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"""
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", content_types)
zf.writestr("_rels/.rels", rels)
zf.writestr("word/document.xml", document_xml)
def write_docx(markdown: str, output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
if not write_docx_with_python_docx(markdown, output):
write_minimal_docx(markdown, output)
def render_one(structured_path: Path, verification_path: Path, language: str, output_md: Path, output_html: Path, output_docx: Path) -> dict[str, str]:
structured = structured_path.read_text(encoding="utf-8", errors="ignore")
verification = verification_path.read_text(encoding="utf-8", errors="ignore")
markdown = build_markdown(structured, verification, language)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_md.write_text(markdown, encoding="utf-8")
output_html.write_text(markdown_to_html(markdown), encoding="utf-8")
write_docx(markdown, output_docx)
return {"markdown": str(output_md), "html": str(output_html), "docx": str(output_docx)}
def render_manifest(manifest_path: Path) -> list[dict[str, str]]:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
outputs: list[dict[str, str]] = []
language = manifest.get("language", "中文")
for paper in manifest.get("papers", []):
result = render_one(
Path(paper["structured_json_file"]),
Path(paper["verification_json_file"]),
paper.get("language") or language,
Path(paper["final_markdown_file"]),
Path(paper["final_html_file"]),
Path(paper["final_docx_file"]),
)
outputs.append({"paper_id": paper.get("id", ""), **result})
return outputs
def main() -> int:
parser = argparse.ArgumentParser(description="Render paper analysis reports from generated JSON files.")
parser.add_argument("--manifest", help="Manifest created by prepare_papers.py. If set, per-paper paths are read from the manifest.")
parser.add_argument("--structured-json", help="Path to structured_result.json for single-paper mode.")
parser.add_argument("--verification-json", help="Path to verification_result.json for single-paper mode.")
parser.add_argument("--language", default="中文", choices=["中文", "英文"])
parser.add_argument("--output-md", help="Output Markdown path for single-paper mode.")
parser.add_argument("--output-html", help="Output rendered HTML path for single-paper mode.")
parser.add_argument("--output-docx", help="Output DOCX path for single-paper mode.")
args = parser.parse_args()
if args.manifest:
outputs = render_manifest(Path(args.manifest).expanduser())
print(json.dumps({"outputs": outputs}, ensure_ascii=False, indent=2))
return 0
required = [args.structured_json, args.verification_json, args.output_md, args.output_html, args.output_docx]
if not all(required):
parser.error("Either --manifest or all single-paper paths are required.")
result = render_one(
Path(args.structured_json).expanduser(),
Path(args.verification_json).expanduser(),
args.language,
Path(args.output_md).expanduser(),
Path(args.output_html).expanduser(),
Path(args.output_docx).expanduser(),
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
FILE:references/prompt_templates.md
# Prompt templates
These templates are adapted from the uploaded Dify DSL `论文分析系统_方案A_结构化证据增强版`.
## Structured extraction prompt
Use after `prepare_papers.py` has created section files. The script writes a filled prompt to `prompts/01_structured_extraction_prompt.md`; prefer the filled file over manually copying this template.
```text
你是一位严谨的论文信息抽取器。请严格基于给定论文内容,输出一个 JSON 对象,不要输出 Markdown、不要解释、不要加 ```json。
输出字段必须包含:
{
"title": "",
"task": "",
"background": "",
"problem_statement": "",
"method_name": "",
"method_core": "",
"datasets": [],
"baselines": [],
"metrics": [],
"main_results": [
{"dataset": "", "metric": "", "value": "", "baseline": "", "improvement": ""}
],
"ablations": [],
"limitations": [],
"claims": [],
"contributions": [],
"evidence_spans": [
{"field": "", "claim": "", "evidence": ""}
]
}
规则:
1. 只能写原文出现或可直接推出的信息,不要脑补。
2. 优先使用对应章节抽取信息;如果某章节为空或信息不足,必须从全文补充。
3. datasets、baselines、metrics 不允许因为章节为空而直接写空数组,必须先检查全文 paper_body、实验结果、实现细节、表格附近文本是否存在相关信息。
4. 只有在全文中也确实找不到时,才能写空字符串或空数组。
5. evidence_spans 至少给 6 条,每条都必须是原文中的直接证据片段或非常贴近原文的概括。
6. 数值结果优先来自实验部分、结果部分、分析部分或表格附近文本。
7. JSON 键名保持英文;JSON 中的自然语言内容使用用户选择的语言。
```
## Verification prompt
Use only after the structured JSON exists. The script writes a filled template to `prompts/02_verification_prompt_template.md`; paste the actual `structured_result.json` into the `{{structured_json}}` placeholder before sending.
```text
你是一位论文事实核验器。请对下面的结构化抽取结果做一致性校验,并输出 JSON 对象,不要输出 Markdown、不要解释。
输出格式:
{
"overall_score": 0,
"hallucination_risk": "low/medium/high",
"issues": [
{"field": "", "problem": "", "severity": "low/medium/high"}
],
"verified_claims": [
{"claim": "", "status": "supported/weak/unsupported", "evidence": ""}
],
"final_verdict": ""
}
评分规则:
- 5:几乎无幻觉,证据充分
- 4:少量不严谨
- 3:有若干缺证据表述
- 2:存在明显不一致
- 1:大量幻觉或错读
要求:
1. JSON 键名保持英文。
2. JSON 中的自然语言内容使用用户选择的语言。
3. 重点检查 datasets、baselines、metrics、main_results 是否遗漏或误抽。
4. 若抽取结果将原文存在的信息写成空数组或空字符串,请在 issues 中明确指出。
5. verified_claims 至少给 4 条。
```
FILE:references/workflow_mapping.md
# Dify Scheme A to OpenClaw Skill mapping
## Original Dify intent
The uploaded workflow is named `论文分析系统_方案A_结构化证据增强版`. It analyzes one or more uploaded papers and/or paper PDF URLs, extracts structured paper information, performs evidence-focused consistency verification, and exports a Markdown-to-DOCX report.
## Node mapping
| Dify node | Skill implementation |
|---|---|
| `if_empty` + `empty_error` | `prepare_papers.py` exits with `上传的文件和论文URL不能同时为空。` when no inputs are provided. |
| `split_urls` | `prepare_papers.py --urls` accepts repeated values or comma-separated URL lists. |
| `http_download` | `prepare_papers.py` downloads URLs to the batch `downloads/` directory under the Desktop output root. |
| `upload_doc_extract` / `download_doc_extract` | `prepare_papers.py` extracts text from copied local files or downloaded files. |
| `upload_clean` / `download_clean` | `prepare_papers.py` applies the same whitespace normalization and reference/bibliography trimming pattern. |
| `upload_sections` / `download_sections` | `prepare_papers.py` implements the same abstract, intro, method, experiment, conclusion, and `paper_body` section split. |
| `upload_structured` / `download_structured` | The Agent must send `prompts/01_structured_extraction_prompt.md` to the model and save JSON-only output to `generated/structured_result.json`. |
| `upload_verify` / `download_verify` | The Agent must send `prompts/02_verification_prompt_template.md` with actual structured JSON inserted and save JSON-only output to `generated/verification_result.json`. |
| `upload_render` / `download_render` | `render_report.py` aggregates structured and verification JSON into a final report. |
| `upload_docx` / `download_docx` | `render_report.py` writes `.docx`; it also writes `.md` and rendered `.html` for easier Markdown preview. |
## Output directory convention
Default runtime path:
```text
~/Desktop/paper_analysis_results/<YYYYMMDD_HHMMSS>/
```
Per-paper files are placed under:
```text
paper_XX_<filename>/
├── input/
├── text/raw_text.txt
├── text/cleaned_text.txt
├── sections/sections.json
├── prompts/01_structured_extraction_prompt.md
├── prompts/02_verification_prompt_template.md
├── generated/structured_result.json
├── generated/verification_result.json
└── report/
├── final_report.md
├── final_report.html
└── final_report.docx
```
FILE:agents/openai.yaml
interface:
display_name: "Paper Analysis Evidence"
short_description: "Extracts evidence-backed structured paper analysis and consistency checks."
icon: "📄"
color: "#FFEAD5"
Make real phone calls, handle inbound and outbound calls, and check call status with Call-E. Schedule calls, run batch calling tasks, and get call results wi...
---
name: Phone Calls — Call-E
description: Make real phone calls, handle inbound and outbound calls, and check call status with Call-E. Schedule calls, run batch calling tasks, and get call results with transcripts. Supports international calling beyond +1 regions.
license: MIT-0
metadata:
openclaw:
requires:
bins:
- openclaw
- node
---
# Phone Calls — Call-E
🎉 Includes 20 free phone calls — no setup cost to try real calling.
Make real phone calls, handle inbound and outbound calls, and check call status using Call-E.
Call-E supports scheduled calls, batch calling workflows, and provides detailed call results with transcripts. It also supports international calling beyond +1 regions.
Use this skill when the user wants to:
- make a phone call
- call a phone number
- place an outbound call
- receive or handle inbound calls
- call a business or customer
- follow up by phone
- continue an active call
- check call status
This skill handles two things as part of its normal purpose:
1. Prepare the local OpenClaw environment so the `calle` plugin is available.
2. Teach the agent how to use the `calle_*` tools correctly once the plugin is
available.
* * *
## Safety and consent rules
- Installing this plugin is an external software installation.
- Restarting the OpenClaw gateway is a privileged local operation.
- Real phone calls can contact external people or businesses and may create
cost, privacy, or compliance implications.
- Do not place a real call unless the user clearly intends to do so.
- Do not guess phone numbers, country codes, region, or language.
- If the user only wants a script, wording help, roleplay, or a simulated
dialogue, do not use the plugin tools.
* * *
## Trigger phrases
Use this skill when the user expresses intent such as:
- "call this number"
- "make a phone call"
- "call the business"
- "call the customer"
- "place an outbound call"
- "follow up by phone"
- "check the status of that call"
* * *
## When to use this skill
Use this skill when the user wants to:
- install or enable the Call-E plugin
- place a real outbound phone call
- continue a call workflow that uses Call-E
- check the status of a call that has already started
- recover from a missing-plugin situation before making a call
This skill is especially appropriate when the user says they want to make a
phone call directly and the agent should prefer the Call-E workflow instead of
searching broadly across unrelated capabilities.
* * *
## When not to use this skill
Do not use this skill for:
- writing a call script only
- simulated conversations or rehearsal
- general contact lookup that does not require placing a call
- unrelated OpenClaw troubleshooting outside the scope of the Call-E plugin
* * *
## Prerequisite
This skill depends on the Call-E OpenClaw plugin.
If the plugin is missing, install it with:
`bash scripts/openclaw-setup.sh`
This is the preferred install path when the installed skill bundle includes the
packaged setup script locally.
The script installs the published plugin package, enables `calle`, merges the
required OpenClaw config, and may prompt to restart the gateway.
If the packaged script is unavailable in the current environment, run:
`curl -fsSL https://raw.githubusercontent.com/CALLE-AI/call-e-integrations/main/openclaw-setup.sh | bash`
This preserves the previous remote install path for environments that only have
the skill instructions and no packaged script file on disk.
If `curl` is unavailable or the user prefers the manual path, run:
`openclaw plugins install @call-e/openagent`
Then enable the plugin:
`openclaw plugins enable calle`
Then restart the gateway if needed:
`openclaw gateway restart`
If the current session still does not see the plugin tools after restart,
retry the same request in a new session.
* * *
## What gets installed
This setup installs the published Call-E OpenClaw plugin and prepares the
local gateway to load it.
Expected tools after setup:
- `calle_plan_call`
- `calle_run_call`
- `calle_get_call_run`
Source repository:
- https://github.com/CALLE-AI/call-e-integrations
* * *
## Definition of Done
This task is not complete until all of the following are true:
1. the `calle` plugin is installed
2. the plugin is enabled
3. the OpenClaw gateway has been restarted if needed
4. the Call-E tools are available in the current environment, or the user has
been clearly told to retry after restart
5. if the user wanted to place a call, the agent proceeds through the correct
Call-E tool flow
* * *
## Install flow
### Step 1 - Check plugin availability
Prefer using `openclaw plugins list` to determine whether `calle` is already
installed.
If `calle` is already present, do not reinstall it unless the user explicitly
asks to reinstall or repair setup.
### Step 2 - Install and enable plugin if needed
If the plugin is missing, run:
`bash scripts/openclaw-setup.sh`
Use this as the default install command when the packaged skill script exists in
the local skill directory.
The script already installs the published plugin package, enables `calle`,
merges the required OpenClaw config, and may prompt to restart the gateway.
If the packaged script is unavailable, run:
`curl -fsSL https://raw.githubusercontent.com/CALLE-AI/call-e-integrations/main/openclaw-setup.sh | bash`
This keeps the previous remote install path intact.
If `curl` is unavailable or the user wants the manual path instead, run:
`openclaw plugins install @call-e/openagent`
Then run:
`openclaw plugins enable calle`
Use `curl` only for installation or repair of the plugin. Once the plugin tools
are available in the session, do not use `curl`, raw HTTP, or shell commands to
perform real Call-E call actions.
### Step 3 - Restart gateway if needed
If the manual install path was used, or if the script skipped restart, run:
`openclaw gateway restart`
Then tell the user to retry the same request if the current session has not
picked up the plugin yet.
### Step 4 - Verify readiness
A successful setup should make these tools available:
- `calle_plan_call`
- `calle_run_call`
- `calle_get_call_run`
If those tools are not yet available, do not proceed with call execution.
* * *
## Tool flow
Once the plugin is available, use the tools in this order.
### 1. Plan first
Always start with `calle_plan_call`.
Pass the user's latest request in `user_input`.
Only provide structured fields such as `goal`, `language`, `region`, or
`to_phones` when they are explicitly known.
Do not invent or normalize uncertain phone numbers or locale details.
### 2. Run only after planning is ready
Use `calle_run_call` only after planning returns a valid `plan_id` and
`confirm_token`.
Use those values exactly as returned.
Do not start the call unless the user clearly wants to proceed.
### 3. Check status only for an existing call
Use `calle_get_call_run` only when a call has already started and a valid
`run_id` exists.
Summarize the status clearly for the user.
* * *
## Authentication flow
If a Call-E tool returns authentication requirements:
- check for `auth_required`
- check for `login_url`
When present:
1. tell the user to open the browser link
2. ask them to complete login
3. retry the same tool call after login completes
Do not switch to a different tool or invent fallback behavior for protected
actions.
* * *
## Notes for the agent
- Prefer the Call-E workflow quickly when the user clearly means a real phone
call.
- Treat plugin setup as part of the normal workflow, not a separate advanced
task.
- If setup changed the local environment, be explicit that the gateway may
need a restart before tools appear.
- Keep user-facing explanations short: install if needed, authenticate if
needed, then place or inspect the call.
- If execution is blocked because the local environment cannot run commands,
provide either `bash scripts/openclaw-setup.sh` or
`curl -fsSL https://raw.githubusercontent.com/CALLE-AI/call-e-integrations/main/openclaw-setup.sh | bash`,
depending on which install path is actually available, and explain the next
step briefly.
FILE:scripts/openclaw-setup.sh
#!/usr/bin/env bash
set -euo pipefail
PACKAGE_NAME="@call-e/openagent"
PLUGIN_ID="calle"
OPENCLAW_CONFIG_PATH="HOME/.openclaw/openclaw.json"
log() {
printf '[openclaw-setup] %s\n' "$*"
}
fail() {
printf '[openclaw-setup] %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage: ./openclaw-setup.sh
Installs the published OpenClaw plugin package, enables `calle`, merges the
required OpenClaw config, and optionally restarts the gateway after prompting.
EOF
}
require_command() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
fail "Required command not found: cmd"
fi
}
plugin_already_installed() {
local list_output
if ! list_output="$(openclaw plugins list 2>/dev/null)"; then
return 1
fi
if printf '%s\n' "$list_output" | grep -Eq '(^|[[:space:][:punct:]])calle([[:space:][:punct:]]|$)|@call-e/openagent'; then
return 0
fi
return 1
}
merge_openclaw_config() {
mkdir -p "$(dirname "$OPENCLAW_CONFIG_PATH")"
OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" PLUGIN_ID="$PLUGIN_ID" node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
const pluginId = process.env.PLUGIN_ID;
const toolIds = [
"calle_plan_call",
"calle_run_call",
"calle_get_call_run",
];
function fail(message) {
console.error(`[openclaw-setup] message`);
process.exit(1);
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
let data = {};
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, "utf8").trim();
if (raw.length > 0) {
try {
data = JSON.parse(raw);
} catch (error) {
fail(`Failed to parse configPath: error.message`);
}
}
}
if (!isObject(data)) {
fail(`configPath must contain a JSON object at the root.`);
}
const plugins = data.plugins;
if (plugins !== undefined && !isObject(plugins)) {
fail(`configPath field "plugins" must be a JSON object.`);
}
const nextPlugins = isObject(plugins) ? { ...plugins } : {};
const entries = nextPlugins.entries;
if (entries !== undefined && !isObject(entries)) {
fail(`configPath field "plugins.entries" must be a JSON object.`);
}
const nextEntries = isObject(entries) ? { ...entries } : {};
const existingEntry = nextEntries[pluginId];
if (existingEntry !== undefined && !isObject(existingEntry)) {
fail(`configPath field "plugins.entries.pluginId" must be a JSON object.`);
}
nextEntries[pluginId] = {
...(isObject(existingEntry) ? existingEntry : {}),
enabled: true,
};
const allow = nextPlugins.allow;
if (allow !== undefined && !Array.isArray(allow)) {
fail(`configPath field "plugins.allow" must be an array.`);
}
const nextAllow = Array.isArray(allow) ? [...allow] : [];
if (!nextAllow.includes(pluginId)) {
nextAllow.push(pluginId);
}
nextPlugins.entries = nextEntries;
nextPlugins.allow = nextAllow;
data.plugins = nextPlugins;
const tools = data.tools;
if (tools !== undefined && !isObject(tools)) {
fail(`configPath field "tools" must be a JSON object.`);
}
const nextTools = isObject(tools) ? { ...tools } : {};
const alsoAllow = nextTools.alsoAllow;
if (alsoAllow !== undefined && !Array.isArray(alsoAllow)) {
fail(`configPath field "tools.alsoAllow" must be an array.`);
}
const nextAlsoAllow = Array.isArray(alsoAllow) ? [...alsoAllow] : [];
for (const toolId of toolIds) {
if (!nextAlsoAllow.includes(toolId)) {
nextAlsoAllow.push(toolId);
}
}
nextTools.alsoAllow = nextAlsoAllow;
data.tools = nextTools;
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `JSON.stringify(data, null, 2)\n`);
NODE
}
prompt_restart() {
local prompt_input
prompt_input="/dev/stdin"
if [ -r /dev/tty ]; then
prompt_input="/dev/tty"
elif [ ! -t 0 ]; then
log "No interactive terminal detected. Skipped gateway restart."
log "Run \`openclaw gateway restart\` manually when you are ready."
return 0
fi
local answer
read -r -p "Restart openclaw gateway now? [y/N] " answer <"$prompt_input" || true
case "$answer" in
[Yy]|[Yy][Ee][Ss])
log "Restarting openclaw gateway"
openclaw gateway restart
;;
*)
log "Skipped gateway restart"
log "Run \`openclaw gateway restart\` manually when you are ready."
;;
esac
}
main() {
if [ "-" = "--help" ]; then
usage
exit 0
fi
if [ "$#" -ne 0 ]; then
usage >&2
exit 1
fi
require_command openclaw
require_command node
if plugin_already_installed; then
log "PLUGIN_ID already appears in \`openclaw plugins list\`; skipping install"
else
log "Installing PACKAGE_NAME"
if ! openclaw plugins install "$PACKAGE_NAME"; then
cat >&2 <<'EOF'
[openclaw-setup] Install failed.
[openclaw-setup] If the error mentions `429 Rate limit exceeded`, configure
[openclaw-setup] ~/.config/clawhub/config.json with a valid ClawHub access token
[openclaw-setup] and rerun this script.
EOF
exit 1
fi
fi
log "Enabling PLUGIN_ID"
openclaw plugins enable "$PLUGIN_ID"
log "Merging OPENCLAW_CONFIG_PATH"
merge_openclaw_config
prompt_restart
log "Installed plugins"
openclaw plugins list
cat <<'EOF'
[openclaw-setup] Next checks:
[openclaw-setup] 1. Open OpenClaw Control UI and inspect `Tools -> Available Right Now`.
[openclaw-setup] 2. Confirm `calle_plan_call`, `calle_run_call`, and `calle_get_call_run` are present.
[openclaw-setup] 3. Trigger one protected tool call to start the browser login flow.
EOF
}
main "$@"
Aggregate and filter multiple RSS feeds to fetch, summarize, deduplicate, and monitor news articles by keywords and sources.
# rss-news-aggregator
## 技能概述
RSS 订阅聚合与新闻抓取工具。支持多源 RSS 订阅抓取、文章摘要提取、关键词过滤、去重排序,自动聚合多平台新闻源为统一的阅读流。
## 何时使用
- 需要自动抓取多个网站/博客的最新文章时
- 需要监控特定关键词在行业新闻中的出现时
- 需要对文章进行自动摘要和分类时
- 需要将多个信息源合并为统一输出时
- 需要定时获取新闻更新并做简单分析时
## 使用方法
### 基础用法
```python
from scripts.rss_engine import RSSAggregator
agg = RSSAggregator()
# 添加订阅源
agg.add_feed("https://news.ycombinator.com/rss", name="Hacker News")
agg.add_feed("https://feeds.arstechnica.com/arstechnica/index", name="Ars Technica")
# 抓取所有文章
articles = agg.fetch_all(limit=20)
# -> [{"title": "...", "link": "...", "summary": "...", "source": "Hacker News", "published": "..."}]
# 按关键词过滤
filtered = agg.filter_by_keyword(articles, ["AI", "Python", "cloud"])
# 生成摘要报告
report = agg.generate_summary(filtered)
```
## 文件结构
```
rss-news-aggregator/
├── SKILL.md
├── README.md
├── requirements.txt
├── scripts/
│ └── rss_engine.py # 核心引擎
├── examples/
│ └── basic_usage.py # 使用示例
└── tests/
└── test_rss.py # 单元测试
```
## 依赖
- `feedparser` — RSS/Atom 解析
- `requests` — HTTP 请求
- `html2text` — HTML 转纯文本摘要
## 标签
rss, news, aggregation, feed, monitoring, content
FILE:README.md
# RSS News Aggregator
RSS 新闻聚合器 — 多源订阅抓取、过滤、摘要一站式工具。
## Features
| 功能 | 说明 |
|------|------|
| 多源订阅 | 支持 RSS/Atom 多种格式,同时管理多个订阅源 |
| 文章抓取 | 自动抓取标题、链接、发布时间、摘要、作者 |
| 关键词过滤 | 按关键词白名单/黑名单过滤文章 |
| 自动摘要 | 提取文章正文前 N 字符作为摘要 |
| 去重排序 | 按发布时间排序,去除重复链接 |
| 导出报告 | 生成 Markdown/HTML 格式聚合报告 |
| 内置源 | 预置科技、AI、开发等热门中文/英文 RSS 源 |
## Quick Start
```python
from scripts.rss_engine import RSSAggregator
agg = RSSAggregator()
# 1. 添加订阅源
agg.add_feed("https://news.ycombinator.com/rss", name="Hacker News")
agg.add_feed("https://rsshub.app/github/trending/daily/python", name="GitHub Trending Python")
# 2. 抓取文章
articles = agg.fetch_all(limit=10)
print(f"抓取到 {len(articles)} 篇文章")
# 3. 按关键词过滤
filtered = agg.filter_by_keyword(articles, ["AI", "LLM", "Python"])
print(f"过滤后 {len(filtered)} 篇相关文章")
# 4. 生成摘要报告
report = agg.generate_markdown_report(filtered, title="今日科技要闻")
print(report)
# 5. 使用内置热门源
popular = agg.get_builtin_feeds("tech")
for name, url in popular.items():
agg.add_feed(url, name=name)
```
## Built-in Feeds
按分类预置的热门订阅源:
| 分类 | 包含源 |
|------|--------|
| `tech` | Hacker News, Ars Technica, TechCrunch, The Verge |
| `ai` | AI News, Paper Digest, HuggingFace Blog |
| `dev` | GitHub Trending, Dev.to, StackOverflow Blog |
| `cn` | 36氪, 少数派, 阮一峰博客 |
```python
# 获取分类下的源列表
tech_feeds = agg.get_builtin_feeds("tech")
ai_feeds = agg.get_builtin_feeds("ai")
cn_feeds = agg.get_builtin_feeds("cn")
```
## Installation
```bash
pip install -r requirements.txt
```
依赖:
- `feedparser>=6.0` — RSS/Atom 解析
- `requests>=2.31` — HTTP 请求
- `html2text>=2024.1` — HTML 转纯文本
## License
MIT
FILE:examples/basic_usage.py
"""
RSS News Aggregator - 基础使用示例
"""
from scripts.rss_engine import RSSAggregator
def main():
agg = RSSAggregator()
print("=" * 50)
print("示例 1: 添加自定义 RSS 源并抓取")
print("=" * 50)
agg.add_feed("https://news.ycombinator.com/rss", name="Hacker News")
articles = agg.fetch_all(limit_per_feed=5, total_limit=10)
print(f"抓取到 {len(articles)} 篇文章")
for a in articles[:3]:
print(f" - [{a['source']}] {a['title'][:60]}...")
print("\n" + "=" * 50)
print("示例 2: 使用内置热门源")
print("=" * 50)
agg2 = RSSAggregator()
feeds = agg2.get_builtin_feeds("tech")
print(f"内置 tech 分类源: {list(feeds.keys())}")
print("\n" + "=" * 50)
print("示例 3: 关键词过滤")
print("=" * 50)
demo_articles = [
{"title": "New AI model released by OpenAI", "summary": "GPT-5 is here", "source": "AI News", "link": "#", "published": "2026-04-27"},
{"title": "Python 4.0 roadmap announced", "summary": "Major changes coming", "source": "Dev.to", "link": "#", "published": "2026-04-26"},
{"title": "Cloud costs optimization guide", "summary": "Save money on AWS", "source": "TechCrunch", "link": "#", "published": "2026-04-25"},
]
filtered = agg2.filter_by_keyword(demo_articles, ["AI", "Python"])
print(f"关键词 'AI' 或 'Python' 匹配到 {len(filtered)} 篇文章:")
for a in filtered:
print(f" - {a['title']}")
print("\n" + "=" * 50)
print("示例 4: 生成 Markdown 报告")
print("=" * 50)
report = agg2.generate_markdown_report(demo_articles, title="今日精选")
print(report[:800] + "\n...")
print("\n" + "=" * 50)
print("示例 5: 按来源筛选")
print("=" * 50)
from_dev = agg2.search_by_source(demo_articles, "Dev")
print(f"来自 Dev 源的文章: {[a['title'] for a in from_dev]}")
if __name__ == "__main__":
main()
FILE:requirements.txt
feedparser>=6.0.0
requests>=2.31.0
html2text>=2024.2.26
FILE:scripts/rss_engine.py
"""
RSS News Aggregator - RSS订阅聚合与新闻抓取引擎
"""
import feedparser
import requests
import html2text
from datetime import datetime
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
class RSSAggregator:
"""RSS 订阅聚合器:多源抓取、过滤、摘要、报告"""
# 内置热门 RSS 源
BUILTIN_FEEDS = {
"tech": {
"Hacker News": "https://news.ycombinator.com/rss",
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
"TechCrunch": "https://techcrunch.com/feed/",
},
"ai": {
"HuggingFace Blog": "https://huggingface.co/blog/feed.xml",
"AI News": "https://www.artificialintelligence-news.com/feed/",
},
"dev": {
"Dev.to": "https://dev.to/feed",
"StackOverflow Blog": "https://stackoverflow.blog/feed/",
},
"cn": {
"阮一峰科技周刊": "https://github.com/ruanyf/weekly/releases.atom",
},
}
def __init__(self, timeout: int = 15):
self.feeds: Dict[str, str] = {}
self.timeout = timeout
self._h2t = html2text.HTML2Text()
self._h2t.ignore_links = False
self._h2t.ignore_images = True
def add_feed(self, url: str, name: str) -> None:
"""添加 RSS 订阅源"""
self.feeds[name] = url
def remove_feed(self, name: str) -> None:
"""移除订阅源"""
self.feeds.pop(name, None)
def list_feeds(self) -> Dict[str, str]:
"""列出所有已添加的订阅源"""
return dict(self.feeds)
def get_builtin_feeds(self, category: str) -> Dict[str, str]:
"""获取内置分类订阅源"""
return dict(self.BUILTIN_FEEDS.get(category, {}))
def _parse_date(self, entry) -> Optional[str]:
"""解析文章发布时间"""
if hasattr(entry, 'published'):
return entry.published
if hasattr(entry, 'updated'):
return entry.updated
return None
def _extract_summary(self, entry) -> str:
"""提取文章摘要"""
# 优先使用 summary
raw = ""
if hasattr(entry, 'summary'):
raw = entry.summary
elif hasattr(entry, 'description'):
raw = entry.description
elif hasattr(entry, 'content'):
raw = entry.content[0].value if entry.content else ""
# 转为纯文本并截断
try:
text = self._h2t.handle(raw)
text = text.replace('\n', ' ').strip()
return text[:300] + ("..." if len(text) > 300 else "")
except Exception:
return raw[:300] + ("..." if len(raw) > 300 else "")
def fetch_feed(self, name: str, url: str, limit: int = 10) -> List[Dict[str, Any]]:
"""抓取单个 RSS 源的文章"""
articles = []
try:
feed = feedparser.parse(url, request_headers={"User-Agent": "RSSAggregator/1.0"})
for entry in feed.entries[:limit]:
article = {
"title": getattr(entry, 'title', 'Untitled'),
"link": getattr(entry, 'link', ''),
"published": self._parse_date(entry),
"summary": self._extract_summary(entry),
"source": name,
"author": getattr(entry, 'author', ''),
}
articles.append(article)
except Exception as e:
articles.append({
"title": f"[ERROR] Failed to fetch {name}",
"link": "",
"published": None,
"summary": str(e),
"source": name,
"author": "",
})
return articles
def fetch_all(self, limit_per_feed: int = 10, total_limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""抓取所有订阅源的文章"""
all_articles = []
for name, url in self.feeds.items():
articles = self.fetch_feed(name, url, limit=limit_per_feed)
all_articles.extend(articles)
# 去重(按链接)
seen = set()
unique = []
for a in all_articles:
link = a.get("link", "")
if link and link not in seen:
seen.add(link)
unique.append(a)
elif not link:
unique.append(a)
# 按发布时间排序(如果有)
try:
unique.sort(key=lambda x: x.get("published") or "", reverse=True)
except Exception:
pass
if total_limit:
unique = unique[:total_limit]
return unique
def filter_by_keyword(self, articles: List[Dict[str, Any]], keywords: List[str], mode: str = "include") -> List[Dict[str, Any]]:
"""按关键词过滤文章
mode: include(包含任一关键词) / exclude(排除所有关键词)
"""
if not keywords:
return articles
keywords = [k.lower() for k in keywords]
filtered = []
for article in articles:
text = f"{article.get('title', '')} {article.get('summary', '')}".lower()
has_keyword = any(k in text for k in keywords)
if mode == "include" and has_keyword:
filtered.append(article)
elif mode == "exclude" and not has_keyword:
filtered.append(article)
return filtered
def generate_markdown_report(self, articles: List[Dict[str, Any]], title: str = "RSS 聚合报告") -> str:
"""生成 Markdown 格式聚合报告"""
lines = [f"# {title}", f"\n生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n", f"共 {len(articles)} 篇文章\n", "---\n"]
for article in articles:
lines.append(f"## {article.get('title', 'Untitled')}")
lines.append(f"- **来源**: {article.get('source', 'Unknown')}")
if article.get('published'):
lines.append(f"- **时间**: {article['published']}")
if article.get('author'):
lines.append(f"- **作者**: {article['author']}")
if article.get('link'):
lines.append(f"- **链接**: {article['link']}")
if article.get('summary'):
lines.append(f"\n{article['summary']}\n")
lines.append("---\n")
return "\n".join(lines)
def generate_text_report(self, articles: List[Dict[str, Any]], title: str = "RSS 聚合报告") -> str:
"""生成纯文本格式聚合报告"""
lines = [f"=== {title} ===", f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}", f"共 {len(articles)} 篇文章\n"]
for i, article in enumerate(articles, 1):
lines.append(f"[{i}] {article.get('title', 'Untitled')}")
lines.append(f" 来源: {article.get('source', 'Unknown')}")
if article.get('published'):
lines.append(f" 时间: {article['published']}")
if article.get('link'):
lines.append(f" 链接: {article['link']}")
if article.get('summary'):
lines.append(f" 摘要: {article['summary'][:200]}")
lines.append("")
return "\n".join(lines)
def search_by_source(self, articles: List[Dict[str, Any]], source_name: str) -> List[Dict[str, Any]]:
"""按来源名称筛选文章"""
return [a for a in articles if source_name.lower() in a.get("source", "").lower()]
FILE:tests/test_rss.py
"""
RSS News Aggregator 单元测试
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from scripts.rss_engine import RSSAggregator
def test_add_remove_feed():
agg = RSSAggregator()
agg.add_feed("https://example.com/rss", name="Test Feed")
assert "Test Feed" in agg.list_feeds()
agg.remove_feed("Test Feed")
assert "Test Feed" not in agg.list_feeds()
print("✓ test_add_remove_feed passed")
def test_builtin_feeds():
agg = RSSAggregator()
tech = agg.get_builtin_feeds("tech")
assert "Hacker News" in tech
ai = agg.get_builtin_feeds("ai")
assert len(ai) > 0
empty = agg.get_builtin_feeds("nonexistent")
assert empty == {}
print("✓ test_builtin_feeds passed")
def test_filter_by_keyword():
agg = RSSAggregator()
articles = [
{"title": "Python new features", "summary": "Great language"},
{"title": "JavaScript trends", "summary": "Web dev"},
{"title": "Python vs AI", "summary": "Comparison"},
]
filtered = agg.filter_by_keyword(articles, ["Python"])
assert len(filtered) == 2
assert all("Python" in a["title"] for a in filtered)
print("✓ test_filter_by_keyword passed")
def test_filter_exclude():
agg = RSSAggregator()
articles = [
{"title": "Python news", "summary": "Code"},
{"title": "Java update", "summary": "VM"},
{"title": "Rust safety", "summary": "Memory"},
]
filtered = agg.filter_by_keyword(articles, ["Python"], mode="exclude")
assert len(filtered) == 2
assert all("Python" not in a["title"] for a in filtered)
print("✓ test_filter_exclude passed")
def test_generate_markdown_report():
agg = RSSAggregator()
articles = [
{"title": "Test Article", "source": "Test", "link": "https://example.com", "summary": "Summary here", "published": "2026-04-27"},
]
report = agg.generate_markdown_report(articles, title="Test Report")
assert "# Test Report" in report
assert "Test Article" in report
assert "https://example.com" in report
print("✓ test_generate_markdown_report passed")
def test_generate_text_report():
agg = RSSAggregator()
articles = [
{"title": "Test Article", "source": "Test", "link": "https://example.com", "summary": "Summary"},
]
report = agg.generate_text_report(articles, title="Test Report")
assert "Test Report" in report
assert "Test Article" in report
print("✓ test_generate_text_report passed")
def test_search_by_source():
agg = RSSAggregator()
articles = [
{"title": "A1", "source": "Dev.to"},
{"title": "A2", "source": "Dev Community"},
{"title": "A3", "source": "Hacker News"},
]
result = agg.search_by_source(articles, "Dev")
assert len(result) == 2
print("✓ test_search_by_source passed")
def test_fetch_feed_error_handling():
agg = RSSAggregator()
# 测试无效 URL 的错误处理
result = agg.fetch_feed("Bad Feed", "https://invalid-url-that-does-not-exist-12345.com/feed", limit=1)
assert len(result) >= 0 # feedparser 可能返回空或错误条目
print("✓ test_fetch_feed_error_handling passed")
if __name__ == "__main__":
test_add_remove_feed()
test_builtin_feeds()
test_filter_by_keyword()
test_filter_exclude()
test_generate_markdown_report()
test_generate_text_report()
test_search_by_source()
test_fetch_feed_error_handling()
print("\n所有测试通过! ✅")