Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ node_modules/
coverage/
test-results/
playwright-report/
.data/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format

## [Unreleased]

### Added

- A CNY 29 concierge learning-pack offer for validating purchase intent before building automated billing.
- Consent-based, minimal private lead storage with validation, anti-spam controls, and no automatic charge.
- Identity-free offer-view and form-open events with a local conversion report.
- A local paid-sale ledger command so reports distinguish purchase intent from confirmed revenue.

## [1.0.0] - 2026-08-11

### Added
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- 公开网页提取标题、摘要、章节与正文要点。
- 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。
- 使用闪卡复习关键概念,并通过一道即时反馈的小测验巩固理解。
- 用 `¥29/份` 的首批定制学习包验证真实付费意愿,提交只登记意向,不会自动扣款。
- 浮窗可以隐藏为右下角入口并随时恢复。

## 运行
Expand Down Expand Up @@ -36,3 +37,28 @@ npm run ci
- 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。

当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。

## 首批付费验证

成功分析后会显示定制学习包入口,商品为学习路线、10 张闪卡、5 道带解释的测验和一个实战任务,早期验证价为 `¥29/份`。用户提交主题、目标和联系方式后,由运营者在 24 小时内人工确认范围与付款方式;系统本身不会收款。

购买意向默认保存在未纳入 Git 的 `.data/paid-pack-interests.jsonl`,也可以通过 `QUICKLEARN_LEAD_FILE` 指定私有持久化路径。记录只包含编号、商品版本、价格、主题、目标、联系方式和提交时间,不保存 IP、User-Agent、网页正文、来源摘录或完整分析上下文。运营者应限制文件访问,仅为履约联系使用,并在意向失效或用户要求时删除对应记录。

HTTP 服务只公开界面所需的三个静态文件,不允许通过 URL 读取 `.data`、服务端源码或 Git 元数据。正式部署仍应把 `QUICKLEARN_LEAD_FILE` 和 `QUICKLEARN_EVENT_FILE` 指向有备份且不在公开目录中的私有持久化卷。

商品展示与表单打开事件保存在 `.data/paid-pack-events.jsonl`,每条只有事件名和时间,不使用 Cookie 或身份标识。查看漏斗和待跟进意向:

```bash
npm run commercial:report
```

确认实际到账后,使用意向编号记录首笔收入,再查看更新后的转化漏斗:

```bash
npm run commercial:mark-paid -- QL-XXXXXXXX
npm run commercial:report
```

付款记录默认写入 `.data/paid-pack-sales.jsonl`,只包含意向编号、商品版本、金额和到账时间,不重复保存联系方式。

首批用户获取、人工收款、交付和验证阈值见 [docs/FIRST-SALE.md](docs/FIRST-SALE.md)。
101 changes: 101 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const state = {
current: null,
lastInput: "",
asking: false,
offerSubmitting: false,
study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null }
};

Expand All @@ -23,6 +24,15 @@ function showToast(message) {
showToast.timer = setTimeout(() => toast.classList.remove("show"), 1900);
}

function recordCommercialEvent(event) {
fetch("/api/commercial-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event }),
keepalive: true
}).catch(() => {});
}

function showView(name) {
$("#startView").hidden = name !== "start";
$("#loadingView").hidden = name !== "loading";
Expand All @@ -35,6 +45,7 @@ function showView(name) {
function reset() {
state.current = null;
state.lastInput = "";
state.offerSubmitting = false;
resetStudy();
$("#mainInput").value = "";
$("#conversation").innerHTML = "";
Expand Down Expand Up @@ -109,9 +120,18 @@ function renderSummary(result) {
${quickQuestions}
</div>
${studyActions ? `<div class="study-actions">${studyActions}</div>` : ""}
<section class="paid-offer" aria-label="定制学习包">
<div class="paid-offer-copy">
<span>首批定制</span>
<strong>把这个主题做成可执行学习包</strong>
<small>学习路线 · 10 张闪卡 · 5 道测验 · 1 个实战任务</small>
</div>
<button data-offer-open aria-label="定制学习包,29 元每份"><span>定制学习包</span><strong>¥29</strong><i data-lucide="arrow-up-right"></i></button>
</section>
</article>`;
showView("conversation");
icons();
recordCommercialEvent("offer_view");
}

function resetStudy() {
Expand Down Expand Up @@ -150,6 +170,7 @@ function showStudy(mode) {
}

function renderStudyPanel() {
$("#offerPanel")?.remove();
$("#studyPanel")?.remove();
if (!state.study.mode) return;

Expand Down Expand Up @@ -208,6 +229,76 @@ function renderStudyPanel() {
scrollToBottom();
}

function showOffer() {
if (!state.current) return;
recordCommercialEvent("offer_open");
resetStudy();
$("#studyPanel")?.remove();
$("#offerPanel")?.remove();
$("#conversation").insertAdjacentHTML("beforeend", `
<section class="offer-panel" id="offerPanel">
<header class="study-panel-head">
<div><i data-lucide="package-check"></i><strong>定制学习包</strong><small>¥29 / 份</small></div>
<button class="study-icon-button" data-offer-close aria-label="关闭定制学习包" title="关闭"><i data-lucide="x"></i></button>
</header>
<div class="offer-details">
<strong>24 小时内人工确认并交付</strong>
<ul><li>聚焦目标的学习路线</li><li>10 张闪卡与 5 道带解释的测验</li><li>1 个能真正上手的实战任务</li></ul>
</div>
<form class="offer-form" id="offerForm">
<label>学习主题<input name="topic" value="${escapeHtml(state.current.title)}" maxlength="160" required /></label>
<label>想达到什么目标?<textarea name="goal" minlength="5" maxlength="600" required placeholder="例如:两天内理解 MCP,并独立做出一个只读工具"></textarea></label>
<label>联系方式<input name="contact" maxlength="120" required autocomplete="email" placeholder="微信号、邮箱或手机号" /></label>
<label class="offer-honeypot" aria-hidden="true">网站<input name="website" tabindex="-1" autocomplete="off" /></label>
<label class="offer-consent"><input name="consent" type="checkbox" required /><span>同意仅用此联系方式确认本次需求和付款方式,不用于无关营销</span></label>
<button class="offer-submit" type="submit"><span>登记购买意向</span><strong>¥29</strong></button>
<p>提交只登记意向,不会自动扣款;确认需求后再决定是否付款。</p>
</form>
</section>`);
icons();
scrollToBottom();
}

async function submitOffer(form) {
if (state.offerSubmitting) return;
state.offerSubmitting = true;
const submit = form.querySelector("button[type='submit']");
submit.disabled = true;
submit.querySelector("span").textContent = "正在登记";
const data = new FormData(form);
try {
const response = await fetch("/api/paid-pack-interest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic: data.get("topic"),
goal: data.get("goal"),
contact: data.get("contact"),
website: data.get("website"),
consent: data.get("consent") === "on"
})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "暂时无法登记");
$("#offerPanel").innerHTML = `
<div class="offer-success" role="status">
<span><i data-lucide="check"></i></span>
<strong>购买意向已登记</strong>
<p>编号 ${escapeHtml(result.reference)}。我们会通过你留下的联系方式确认范围和付款方式。</p>
<small>本次没有自动扣款。</small>
<button data-offer-close>返回学习</button>
</div>`;
icons();
} catch (error) {
showToast(error.message);
submit.disabled = false;
submit.querySelector("span").textContent = "登记购买意向";
} finally {
state.offerSubmitting = false;
scrollToBottom();
}
}

async function ask(question) {
const value = question.trim();
if (!value || !state.current || state.asking) return;
Expand Down Expand Up @@ -256,6 +347,12 @@ $("#followUpForm").addEventListener("submit", (event) => {
ask($("#followUpInput").value);
});

document.addEventListener("submit", (event) => {
if (!event.target.matches("#offerForm")) return;
event.preventDefault();
submitOffer(event.target);
});

document.addEventListener("click", (event) => {
const example = event.target.closest("[data-example]");
const clarify = event.target.closest("[data-clarify]");
Expand All @@ -266,6 +363,8 @@ document.addEventListener("click", (event) => {
const cardNav = event.target.closest("[data-card-nav]");
const quizOption = event.target.closest("[data-quiz-option]");
const quizReset = event.target.closest("[data-quiz-reset]");
const offerOpen = event.target.closest("[data-offer-open]");
const offerClose = event.target.closest("[data-offer-close]");
if (example) analyze(example.dataset.example);
if (clarify) analyze(clarify.dataset.clarify);
if (question) ask(question.dataset.question);
Expand All @@ -291,6 +390,8 @@ document.addEventListener("click", (event) => {
state.study.quizAnswer = null;
renderStudyPanel();
}
if (offerOpen) showOffer();
if (offerClose) $("#offerPanel")?.remove();
});

$("#newButton").addEventListener("click", reset);
Expand Down
40 changes: 40 additions & 0 deletions docs/FIRST-SALE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 第一笔收入执行手册

## 当前商品

- 用户:正在快速学习 AI 工具或开发技术的中文开发者、独立开发者和转行学习者。
- 场景:看过零散资料,但不知道先学什么、怎样确认自己已经会用。
- 商品:围绕一个具体目标制作学习路线、10 张闪卡、5 道带解释的测验和 1 个实战任务。
- 验证价:`¥29/份`,24 小时内人工确认和交付。
- 边界:登记意向不等于付款;确认主题、目标和交付时间后,再通过双方认可的方式收款。

## 首批 20 位用户

不要先投广告。每天寻找 5 位正在公开提问 MCP、AI 智能体、React 或 Docker 入门问题的人,优先选择问题具体、近期正在实践的人。可以来自 GitHub Issue、技术社群或自己的真实联系人,但不要批量骚扰,也不要抓取私人联系方式。

首次沟通只发一条短消息:

> 我在做一个“把技术主题变成可执行学习包”的小工具。你刚才提到想学「主题」,我可以先免费给你看 30 秒总结;如果方向对,再用 ¥29 做成针对你目标的路线、练习和测验。链接:你的公开访问地址

对没有回应的人不连续追发。对打开表单的人,先确认三个问题:要解决什么任务、已有基础、希望何时完成。范围确认后再收款。

## 24 小时交付

1. 用 `npm run commercial:report` 查看新意向并联系用户。
2. 复述用户目标和交付清单,请对方确认。
3. 通过双方认可的收款方式收取 `¥29`,保留付款凭证,并运行 `npm run commercial:mark-paid -- QL-XXXXXXXX` 记录到账。
4. 制作一份短而具体的学习包,所有内容围绕用户目标。
5. 交付后询问两个问题:哪一部分最有用,是否愿意以同样价格再买另一个主题。
6. 在意向失效、履约结束不再需要,或用户要求时,删除其联系方式记录。

## 七天判断标准

每天记录商品展示、表单打开、购买意向、实际付款和按时交付。前 100 次有效商品展示后再判断:

- 打开率低于 5%:商品名称或结果不够有吸引力,先改定位与文案。
- 打开率至少 5%,意向率低于 15%:价格、信任或表单阻力有问题。
- 至少 3 个有效意向但无人付款:先访谈,不接支付系统。
- 出现 1 笔真实付款:完成第一桶金验证,继续手工卖到 10 单。
- 完成 10 单且重复需求明显:再建设账号、自动支付和规模化生成。

不要把 GitHub Star、访问量或表单点击当收入。当前阶段唯一的核心结果是真实付款和完成交付。
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
"scripts": {
"start": "node server.mjs",
"dev": "node --watch server.mjs",
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js",
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js && node --check scripts/commercial-report.mjs && node --check scripts/commercial-mark-paid.mjs",
"test": "node --test test/*.test.mjs",
"smoke": "npm test",
"ci": "npm run lint && npm run smoke"
"ci": "npm run lint && npm run smoke",
"commercial:report": "node scripts/commercial-report.mjs",
"commercial:mark-paid": "node scripts/commercial-mark-paid.mjs"
},
"engines": {
"node": ">=18"
Expand Down
35 changes: 35 additions & 0 deletions scripts/commercial-mark-paid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { appendFile, chmod, mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";

const reference = process.argv[2]?.trim().toUpperCase();
const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");

if (!/^QL-[A-F0-9]{8}$/.test(reference || "")) {
console.error("用法: npm run commercial:mark-paid -- QL-XXXXXXXX");
process.exitCode = 1;
} else {
const readLines = async (path) => {
try {
return (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line));
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
};
const [leads, sales] = await Promise.all([readLines(leadFile), readLines(saleFile)]);
const lead = leads.find((item) => item.reference === reference);
if (!lead) {
console.error(`未找到意向编号 ${reference}`);
process.exitCode = 1;
} else if (sales.some((item) => item.reference === reference)) {
console.error(`${reference} 已标记付款`);
process.exitCode = 1;
} else {
const sale = { reference, offer: lead.offer, amountCny: lead.amountCny, paidAt: new Date().toISOString() };
await mkdir(dirname(saleFile), { recursive: true, mode: 0o700 });
await appendFile(saleFile, `${JSON.stringify(sale)}\n`, { encoding: "utf8", mode: 0o600 });
await chmod(saleFile, 0o600);
console.log(`已记录 ${reference} 付款 ¥${sale.amountCny}`);
}
}
37 changes: 37 additions & 0 deletions scripts/commercial-report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";

const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
const eventFile = process.env.QUICKLEARN_EVENT_FILE || join(process.cwd(), ".data", "paid-pack-events.jsonl");
const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");

async function readJsonLines(path) {
try {
return (await readFile(path, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
}

const [leads, events, sales] = await Promise.all([readJsonLines(leadFile), readJsonLines(eventFile), readJsonLines(saleFile)]);
const views = events.filter(({ event }) => event === "offer_view").length;
const opens = events.filter(({ event }) => event === "offer_open").length;
const revenue = sales.reduce((total, sale) => total + (Number(sale.amountCny) || 0), 0);
const percent = (value, total) => total ? `${((value / total) * 100).toFixed(1)}%` : "n/a";

console.log(`商品展示: ${views}`);
console.log(`打开表单: ${opens} (${percent(opens, views)})`);
console.log(`购买意向: ${leads.length} (${percent(leads.length, opens)})`);
console.log(`实际付款: ${sales.length} (${percent(sales.length, leads.length)})`);
console.log(`确认收入: ¥${revenue}`);

if (leads.length) {
console.log("\n待跟进意向:");
for (const lead of leads) {
console.log(`${lead.reference}\t${lead.createdAt}\t${lead.topic}\t${lead.contact}\t${lead.goal}`);
}
}
Loading