chore: finalize delivery cleanup
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -24,6 +24,13 @@ logs/
|
||||
.DS_Store
|
||||
verify_output/
|
||||
**/verify_output/
|
||||
frontend/verify_task103/
|
||||
backend/test_color_compare.py
|
||||
backend/test_3sets_e2e.py
|
||||
backend/scripts/run_log.txt
|
||||
test/
|
||||
test/node_modules/
|
||||
test/.env
|
||||
output_e2e/
|
||||
**/output_e2e/
|
||||
screenshot-*.png
|
||||
|
||||
@@ -31,7 +31,8 @@ TEST_PROMPT = "\n".join([
|
||||
"避免美白/祛斑/速效/医用等违规词;必须保持产品包装与参考图一致。",
|
||||
])
|
||||
|
||||
_REF = ROOT / "test/output/01_连通测试.png"
|
||||
_REF_ENV = os.environ.get("REAL_IMAGE_REF", "").strip()
|
||||
_REF = Path(_REF_ENV).expanduser() if _REF_ENV else None
|
||||
|
||||
|
||||
async def _apiports(key, url, ref, model, size):
|
||||
@@ -73,6 +74,11 @@ async def run():
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "codeproxy").lower()
|
||||
out_dir = Path(__file__).parent.parent / "verify_output"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if _REF is None or not _REF.exists() or not _REF.is_file():
|
||||
raise FileNotFoundError(
|
||||
"请通过 REAL_IMAGE_REF 指定本地参考图路径,例如:"
|
||||
"REAL_IMAGE_REF=/path/to/ref.png python3 scripts/real_image_check.py"
|
||||
)
|
||||
ref = _REF.read_bytes()
|
||||
t0 = time.time(); img = None; used = None
|
||||
for prov in [primary, fallback]:
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
2026-06-09 11:39:33,251 INFO [apiports] 出图中 model=gpt-image-2 size=1024x1536
|
||||
2026-06-09 11:41:04,233 INFO HTTP Request: POST https://www.apiports.com/v1/api/generate "HTTP/1.1 200 OK"
|
||||
2026-06-09 11:41:04,236 ERROR [apiports] 失败: 无法解析: []
|
||||
2026-06-09 11:41:04,236 INFO [codeproxy] 出图中 model=gpt-image-2 size=1024x1536
|
||||
2026-06-09 11:42:33,485 INFO HTTP Request: POST https://codeproxy.dev/v1/images/edits "HTTP/1.1 200 OK"
|
||||
|
||||
===== 出图结果 =====
|
||||
provider: codeproxy
|
||||
耗时: 181.2s
|
||||
大小: 2009KB
|
||||
路径: /Users/qiyu/Documents/企业培训项目/万牛会L1准备/北哥小红书产品/Clover/backend/verify_output/素颜霜_封面hook.png
|
||||
====================
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Task 端到端真跑临时验证脚本。
|
||||
|
||||
手动运行:
|
||||
cd backend && python3 test_3sets_e2e.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
|
||||
def main():
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.task import GenerationTask
|
||||
from app.constants.enums import TaskStatus
|
||||
from app.workers.tasks import run_generation_pipeline
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = GenerationTask(
|
||||
workspace_id=3,
|
||||
product_id=1,
|
||||
operator_id=3,
|
||||
theme="伪素颜·黄黑皮提亮·3套正交",
|
||||
text_count=1,
|
||||
image_count=2,
|
||||
track="ai",
|
||||
need_product_image=True,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
task_id = task.id
|
||||
print(f"建task成功 task_id={task_id} image_count=2 (期望3套×2=6张)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print(f"=== 同步执行 run_generation_pipeline({task_id}) ===")
|
||||
result = run_generation_pipeline.apply(args=[task_id]).get()
|
||||
print(f"=== 结果: {result} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,6 +18,9 @@ class _Query:
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
def count(self):
|
||||
return len(self._rows)
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, text_rows, image_rows):
|
||||
@@ -32,26 +35,44 @@ class _Task:
|
||||
image_count = 3
|
||||
|
||||
|
||||
def test_submit_review_requires_each_strategy_to_select_full_image_count():
|
||||
db = _Db(
|
||||
text_rows=[("A",), ("B",), ("C",)],
|
||||
image_rows=[("A", 1), ("A", 2), ("A", 3), ("B", 4), ("C", 5), ("C", 6), ("C", 7)],
|
||||
)
|
||||
def test_submit_review_requires_at_least_one_text_and_one_image():
|
||||
db = _Db(text_rows=[], image_rows=[])
|
||||
|
||||
with pytest.raises(CloverHTTPException) as exc:
|
||||
task_actions._validate_strategy_selection(db, _Task())
|
||||
|
||||
assert "图片未选满:B(1/3)" in exc.value.biz_message
|
||||
assert "请至少选择 1 条文案" in exc.value.biz_message
|
||||
assert "请至少选择 1 张图片" in exc.value.biz_message
|
||||
|
||||
|
||||
def test_submit_review_passes_when_all_strategies_have_text_and_full_images():
|
||||
def test_submit_review_requires_at_least_one_image():
|
||||
db = _Db(text_rows=[("A",)], image_rows=[])
|
||||
|
||||
with pytest.raises(CloverHTTPException) as exc:
|
||||
task_actions._validate_strategy_selection(db, _Task())
|
||||
|
||||
assert exc.value.biz_message == "请至少选择 1 张图片"
|
||||
|
||||
|
||||
def test_submit_review_requires_at_least_one_text():
|
||||
db = _Db(text_rows=[], image_rows=[("A", 1)])
|
||||
|
||||
with pytest.raises(CloverHTTPException) as exc:
|
||||
task_actions._validate_strategy_selection(db, _Task())
|
||||
|
||||
assert exc.value.biz_message == "请至少选择 1 条文案"
|
||||
|
||||
|
||||
def test_submit_review_passes_with_one_text_and_one_image():
|
||||
db = _Db(text_rows=[("B",)], image_rows=[("C", 7)])
|
||||
|
||||
task_actions._validate_strategy_selection(db, _Task())
|
||||
|
||||
|
||||
def test_submit_review_does_not_require_all_strategies_or_full_image_count():
|
||||
db = _Db(
|
||||
text_rows=[("A",), ("B",), ("C",)],
|
||||
image_rows=[
|
||||
("A", 1), ("A", 2), ("A", 3),
|
||||
("B", 4), ("B", 5), ("B", 6),
|
||||
("C", 7), ("C", 8), ("C", 9),
|
||||
],
|
||||
text_rows=[("A",)],
|
||||
image_rows=[("B", 4)],
|
||||
)
|
||||
|
||||
task_actions._validate_strategy_selection(db, _Task())
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1,26 +0,0 @@
|
||||
'use strict';
|
||||
// 第1步:连通性测试 —— 1张素颜霜产品图 + 最简prompt,验证 codeproxy/gpt-image-2/key 全打通
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { imageEdit, saveResult } = require('./aiClient');
|
||||
|
||||
const PRODUCT_IMG = '/Users/qiyu/Documents/企业培训项目/万牛会L1准备/产品包系统测试资料/产品白底图/素颜霜/IMG_9439.JPG';
|
||||
const OUT_DIR = path.join(__dirname, 'output');
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
console.log('=== 第1步 连通性测试 ===');
|
||||
console.log('产品图:', PRODUCT_IMG, fs.existsSync(PRODUCT_IMG) ? '✓存在' : '✗缺失');
|
||||
|
||||
const prompt = '生成一张小红书风格的素颜霜产品图,竖版3:4,明亮干净,真实实拍质感,产品摆在浅色台面上,自然光。保持产品包装、颜色、质感与参考图一致。';
|
||||
try {
|
||||
const result = await imageEdit({ images: [PRODUCT_IMG], prompt, traceId: 'connect-test' });
|
||||
const out = path.join(OUT_DIR, '01_连通测试.png');
|
||||
await saveResult(result, out);
|
||||
console.log('\n✅ 链路打通!返回类型:', result.type, '→ 已保存:', out);
|
||||
} catch (e) {
|
||||
console.log('\n❌ 失败:', e.message);
|
||||
console.log('排查方向: 401=key权限问题 / 404=端点或模型名 / 429=配额 / 超时=中转站慢');
|
||||
}
|
||||
}
|
||||
main();
|
||||
@@ -1,69 +0,0 @@
|
||||
'use strict';
|
||||
// step-2 基线生图:用移植的素颜霜prompt,每张带产品图,生成3张到 output/
|
||||
// 探地基基线 —— 不是达标测试,是看gpt-image-2出素颜霜种草图的底子
|
||||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { imageEdit, saveResult } = require('./aiClient');
|
||||
const { PRODUCT, ROLES, buildImagePrompt } = require('./suyanshuang_data');
|
||||
|
||||
const MODEL = process.env.MODEL_IMAGE || 'gpt-image-2';
|
||||
const SIZE = process.env.IMAGE_SIZE || '1024x1536';
|
||||
const PRODUCT_IMG = '/Users/qiyu/Documents/企业培训项目/万牛会L1准备/产品包系统测试资料/产品白底图/素颜霜/IMG_9439.JPG';
|
||||
const OUT_DIR = path.join(__dirname, 'output');
|
||||
|
||||
const JOBS = [
|
||||
{ role: 'hook', file: '02_封面hook.png' },
|
||||
{ role: 'texture', file: '03_质感texture.png' },
|
||||
{ role: 'closer', file: '04_软转化closer.png' },
|
||||
];
|
||||
|
||||
async function runOne(job) {
|
||||
const r = ROLES[job.role];
|
||||
const prompt = buildImagePrompt(job.role);
|
||||
const traceId = `clover-baseline-${job.role}`;
|
||||
console.log(`\n── [${r.name}] 开始生成 ──`);
|
||||
console.log(` 模型=${MODEL} 尺寸=${SIZE} 产品图=${path.basename(PRODUCT_IMG)}`);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = await imageEdit({
|
||||
images: [PRODUCT_IMG],
|
||||
prompt,
|
||||
traceId,
|
||||
model: MODEL,
|
||||
size: SIZE,
|
||||
});
|
||||
const outPath = path.join(OUT_DIR, job.file);
|
||||
await saveResult(result, outPath);
|
||||
const ms = Date.now() - t0;
|
||||
const kb = fs.existsSync(outPath) ? Math.round(fs.statSync(outPath).size / 1024) : 0;
|
||||
console.log(` ✅ 完成 用时${ms}ms 大小${kb}KB → ${job.file}`);
|
||||
return { role: job.role, ok: true, ms, kb, file: job.file };
|
||||
} catch (e) {
|
||||
const ms = Date.now() - t0;
|
||||
console.log(` ❌ 失败 用时${ms}ms:${e.message}`);
|
||||
return { role: job.role, ok: false, ms, err: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
if (!fs.existsSync(PRODUCT_IMG)) {
|
||||
console.error(`产品图不存在:${PRODUCT_IMG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`===== Clover 基线生图测试 =====`);
|
||||
console.log(`产品:${PRODUCT.name}|成分:${PRODUCT.ingredients.join('/')}`);
|
||||
const results = [];
|
||||
for (const job of JOBS) {
|
||||
// 串行跑,避免并发触发中转站限流;一张一张看结果
|
||||
results.push(await runOne(job));
|
||||
}
|
||||
console.log(`\n===== 汇总 =====`);
|
||||
for (const r of results) {
|
||||
if (r.ok) console.log(` ${r.role}: ✅ ${r.ms}ms ${r.kb}KB`);
|
||||
else console.log(` ${r.role}: ❌ ${r.err}`);
|
||||
}
|
||||
const okN = results.filter((r) => r.ok).length;
|
||||
console.log(`\n成功 ${okN}/${JOBS.length} 张,输出目录:${OUT_DIR}`);
|
||||
})();
|
||||
109
test/aiClient.js
109
test/aiClient.js
@@ -1,109 +0,0 @@
|
||||
'use strict';
|
||||
// Clover 图片质量测试 · codeproxy 调用核心
|
||||
// 复用自 xhs-tool/backend/infrastructure/aiClient.js(已实测跑通的对的路),简化:去降级、去sharp
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
const FormData = require('form-data');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const KEY = process.env.AI_API_KEY;
|
||||
const BASE_URL = process.env.AI_BASE_URL;
|
||||
const MODEL_IMAGE = process.env.MODEL_IMAGE || 'gpt-image-2';
|
||||
const IMAGE_TIMEOUT = 300000;
|
||||
|
||||
if (!KEY) throw new Error('AI_API_KEY 未配置(见 test/.env)');
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function toBuffer(img) {
|
||||
if (Buffer.isBuffer(img)) return img;
|
||||
if (typeof img === 'string' && img.startsWith('data:'))
|
||||
return Buffer.from(img.replace(/^data:[^;]+;base64,/, ''), 'base64');
|
||||
return fs.readFileSync(img); // 本地文件路径
|
||||
}
|
||||
|
||||
// 单次请求:POST /images/edits (multipart)
|
||||
function postEditOnce({ imgList, prompt, m, sz }) {
|
||||
const form = new FormData();
|
||||
form.append('model', m);
|
||||
form.append('prompt', prompt);
|
||||
form.append('size', sz);
|
||||
form.append('n', '1');
|
||||
for (let i = 0; i < imgList.length; i++) {
|
||||
const buf = toBuffer(imgList[i]);
|
||||
form.append('image[]', buf, { filename: `img_${i}.jpg`, contentType: 'image/jpeg' });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => { ac.abort(); req.destroy(new Error('300s timeout')); }, IMAGE_TIMEOUT);
|
||||
const req = https.request(`${BASE_URL}/images/edits`, {
|
||||
method: 'POST',
|
||||
headers: { ...form.getHeaders(), Authorization: `Bearer ${KEY}` },
|
||||
signal: ac.signal,
|
||||
}, (r) => {
|
||||
let body = '';
|
||||
r.on('data', d => body += d);
|
||||
r.on('end', () => { clearTimeout(timer); resolve({ status: r.statusCode, body }); });
|
||||
r.on('error', reject);
|
||||
});
|
||||
req.on('error', (e) => { clearTimeout(timer); reject(e); });
|
||||
form.pipe(req);
|
||||
});
|
||||
}
|
||||
|
||||
// 可重试错误:上游/网关临时故障 + 限流 + 超时
|
||||
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
||||
|
||||
// 带参考图出图:失败自动退避重试(复用 xhs-tool 的重试思路)
|
||||
async function imageEdit({ images, prompt, traceId = 't', model, size, maxRetry = 3 }) {
|
||||
const m = model || MODEL_IMAGE;
|
||||
const sz = size || process.env.IMAGE_SIZE || '1024x1536';
|
||||
const imgList = Array.isArray(images) ? images : [images];
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const res = await postEditOnce({ imgList, prompt, m, sz });
|
||||
console.log(`[imageEdit][${traceId}] try=${attempt}/${maxRetry} model=${m} size=${sz} imgs=${imgList.length} status=${res.status} ms=${Date.now()-t0}`);
|
||||
if (res.status === 200) {
|
||||
const d = JSON.parse(res.body).data[0];
|
||||
if (d.url) return { type: 'url', value: d.url };
|
||||
if (d.b64_json) return { type: 'b64', value: d.b64_json };
|
||||
throw new Error('返回既无 url 也无 b64_json');
|
||||
}
|
||||
lastErr = new Error(`HTTP ${res.status}: ${res.body.slice(0, 300)}`);
|
||||
if (!RETRYABLE.has(res.status)) throw lastErr; // 401/404等不重试,直接抛
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const code = (e.message.match(/HTTP (\d+)/) || [])[1];
|
||||
if (code && !RETRYABLE.has(Number(code))) throw e;
|
||||
}
|
||||
if (attempt < maxRetry) {
|
||||
const wait = 3000 * attempt; // 3s,6s 退避
|
||||
console.log(` ↻ 上游临时故障,${wait/1000}s 后重试…`);
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// 保存结果到文件(url则下载,b64则解码)
|
||||
async function saveResult(result, outPath) {
|
||||
if (result.type === 'b64') {
|
||||
fs.writeFileSync(outPath, Buffer.from(result.value, 'base64'));
|
||||
return outPath;
|
||||
}
|
||||
// url:下载
|
||||
await new Promise((resolve, reject) => {
|
||||
https.get(result.value, (r) => {
|
||||
const chunks = [];
|
||||
r.on('data', c => chunks.push(c));
|
||||
r.on('end', () => { fs.writeFileSync(outPath, Buffer.concat(chunks)); resolve(); });
|
||||
r.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
return outPath;
|
||||
}
|
||||
|
||||
module.exports = { imageEdit, saveResult, toBuffer, sleep };
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
端到端生图验证(补齐后质量检查)
|
||||
- 真实 DB 解密 key → 真实 AIClients → 真实 plan_image_set → 真实调 apiports 出 6 图
|
||||
- 只验证生图链路;文案侧用占位 note,不走文案生成
|
||||
用法(容器内):python3 /app/test/e2e_image_check.py
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
OUT_DIR = "/app/test/output_e2e"
|
||||
REF_IMG = "/app/test/ref_product.png" # 预先拷入的真实参考图
|
||||
IMAGE_COUNT = 6
|
||||
|
||||
|
||||
def load_clients_and_product():
|
||||
"""用真实 DB 解密 key 构建 clients,并取产品 dict。"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.product import Product
|
||||
from app.workers.pipeline_steps import (
|
||||
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
product = db.query(Product).order_by(Product.id.desc()).first()
|
||||
if not product:
|
||||
raise SystemExit("库里没有产品记录")
|
||||
product_dict = build_product_dict(product)
|
||||
# 用产品所属 workspace 的 key(user_id 取 product.workspace_id 同号那条)
|
||||
plain_key = decrypt_user_key(db, product.workspace_id, product.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
plain_key = None
|
||||
return clients, product_dict
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def fake_note(product_dict: dict) -> dict:
|
||||
"""占位文案 note(不走文案生成,只为生图提供 hook/卖点上下文)。"""
|
||||
pts = product_dict.get("selling_points") or ["温和不刺激", "成分干净", "上脸快吸收"]
|
||||
return {
|
||||
"title": f"{product_dict['name']}用一周的真实变化",
|
||||
"content": "姐妹们这个我真的要安利,敏感肌也能用,质地清爽不黏腻,"
|
||||
"坚持用上脸状态肉眼可见地稳定了,分享给同样在找平替的你。",
|
||||
"tags": ["敏感肌", "护肤平替", "成分党", "种草"],
|
||||
"selling_points": pts,
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
if not os.path.isfile(REF_IMG):
|
||||
raise SystemExit(f"参考图不存在:{REF_IMG}(请先拷入)")
|
||||
with open(REF_IMG, "rb") as f:
|
||||
ref_bytes = f.read()
|
||||
print(f"参考图已加载:{len(ref_bytes)} bytes")
|
||||
|
||||
clients, product_dict = load_clients_and_product()
|
||||
print(f"产品:{product_dict['name']} | 类目:{product_dict['category']} "
|
||||
f"| style_tone:{product_dict['style_tone']}")
|
||||
print(f"卖点:{product_dict['selling_points']}")
|
||||
|
||||
note = fake_note(product_dict)
|
||||
|
||||
from app.services.ai_engine.image_gen import generate_storyboard_images
|
||||
results = await generate_storyboard_images(
|
||||
client=clients,
|
||||
note=note,
|
||||
product=product_dict,
|
||||
image_count=IMAGE_COUNT,
|
||||
reference_images=[ref_bytes],
|
||||
)
|
||||
|
||||
ok = 0
|
||||
for i, r in enumerate(results, 1):
|
||||
role = r.get("role", f"img{i}")
|
||||
if r.get("error"):
|
||||
print(f" [{i}] {role} ❌ {r['error']}")
|
||||
continue
|
||||
b = r.get("image_bytes") or b""
|
||||
path = os.path.join(OUT_DIR, f"{i:02d}_{role}.png")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b)
|
||||
ok += 1
|
||||
print(f" [{i}] {role} ✅ {len(b)} bytes → {path}")
|
||||
|
||||
print(f"\n完成:{ok}/{len(results)} 张成功,输出目录 {OUT_DIR}")
|
||||
await clients.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB |
271
test/package-lock.json
generated
271
test/package-lock.json
generated
@@ -1,271 +0,0 @@
|
||||
{
|
||||
"name": "test",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "test",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"form-data": "^4.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "test",
|
||||
"version": "1.0.0",
|
||||
"main": "aiClient.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"form-data": "^4.0.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -1,61 +0,0 @@
|
||||
'use strict';
|
||||
// 素颜霜真实素材 + 移植自产品包image.js的小红书种草prompt
|
||||
// 卖点/成分/标签均取自北哥真实文案(文案3.txt),不杜撰
|
||||
|
||||
const PRODUCT = {
|
||||
name: '倍分子素颜霜',
|
||||
category: '美妆护肤·素颜霜',
|
||||
sellingPoints: ['黄黑皮提亮', '伪素颜自然', '不假白不卡纹', '洗面奶就能卸', '懒人必备'],
|
||||
ingredients: ['烟酰胺(改善暗沉/自然提亮)', '水解珍珠(锁水保湿/匀肤色)', '角鲨烷(裸妆奶油肌/不卡纹)'],
|
||||
texture: '水润奶油质地,丝滑一抹到底,上脸清透不厚重',
|
||||
tags: ['#素颜霜', '#素颜霜分享', '#黄黑皮', '#冷白皮', '#早八', '#伪素颜'],
|
||||
// 合规红线(取自产品包违禁词):禁用 美白/祛斑/速效/医用/药妆;视觉禁 before/after/变白
|
||||
forbidden: ['美白', '祛斑', '速效', '医用', '药妆', '前后对比', '变白'],
|
||||
};
|
||||
|
||||
// 真实拍摄感约束(移植自 xhs-tool single-product.md,补强真实感)
|
||||
const REALISM = `真实拍摄感:模拟iPhone主摄浅景深,光线不均匀有明暗过渡,允许桌面轻微纹理、织物自然褶皱;避免AI完美对称、塑料感无瑕疵、过度饱和。追求"小红书博主随手拍"质感,不是广告大片。`;
|
||||
|
||||
// 违禁规避(移植自 image.js basePrompt 合规段)
|
||||
const COMPLIANCE = `重要限制:禁止生成小红书/App界面截图,禁止点赞评论分享底栏头像等社交App元素;禁止肤色变白、瑕疵消失、功效前后对比等暗示;允许安全的质地状态对比(未推开/推开后、薄涂/厚涂、手背局部肤感);避免"美白/祛斑/速效/医用/药妆"等违规词;中文文字少而清晰,每张只一个主标题,正文点位最多3条,四周留安全边距文字不贴边不被裁切;必须保持产品包装、颜色、质感与参考图一致。`;
|
||||
|
||||
// 3个叙事角色(移植自 image.js storyboardByRole 的美妆护肤分支)
|
||||
const ROLES = {
|
||||
hook: {
|
||||
name: '封面-钩子',
|
||||
title: '黄黑皮逆袭|伪素颜天花板',
|
||||
visual: '自然光生活场景,手持素颜霜或产品在桌面前景,画面有真实肤感/手部细节,像iPhone实拍封面',
|
||||
overlay: '黄黑皮也能拥有的妈生好皮',
|
||||
},
|
||||
texture: {
|
||||
name: '质感-证明',
|
||||
title: '水润奶油质地·一抹到底',
|
||||
visual: '手背或指尖涂抹质地微距,产品放在旁边,自然光,真实皮肤纹理,展示未推开与推开后的真实质地状态',
|
||||
overlay: '清透不厚重·不卡纹不假白',
|
||||
},
|
||||
closer: {
|
||||
name: '软转化-囤货',
|
||||
title: '挖到宝了·姐妹快冲',
|
||||
visual: '产品放在梳妆台/宿舍桌面囤货角,配日常物品,真实分享氛围,轻量种草口吻',
|
||||
overlay: '洗面奶就能卸·懒人必备',
|
||||
},
|
||||
};
|
||||
|
||||
// 组装单张图的完整prompt(移植自 image.js promptFromStoryboard)
|
||||
function buildImagePrompt(roleKey) {
|
||||
const r = ROLES[roleKey];
|
||||
return [
|
||||
`生成一张小红书可上传的独立竖版3:4图文海报/素材图,目标1024x1536,可直接上传的独立图片,不是提示词不是App截图。`,
|
||||
`产品:${PRODUCT.name}(${PRODUCT.category})。`,
|
||||
`本张角色:${r.name}。`,
|
||||
`画面主体:${r.visual}。`,
|
||||
`图上文字(少而精):主标题「${r.title}」,可选点位「${r.overlay}」。`,
|
||||
`核心卖点:${PRODUCT.sellingPoints.join('、')}。`,
|
||||
`质地:${PRODUCT.texture}。`,
|
||||
`视觉风格:小红书种草风,明亮干净,暖色调,自然实拍质感。`,
|
||||
REALISM,
|
||||
COMPLIANCE,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { PRODUCT, ROLES, buildImagePrompt };
|
||||
@@ -1,53 +0,0 @@
|
||||
# Clover · AI生图水印问题 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC|性质:洞3子问题B(生成的图能不能直接发)实测
|
||||
> 触发:倩倩姐指出「AI生图有暗水印,发小红书会被识别,传统靠PS转格式,系统里不能再拉PS」
|
||||
|
||||
## 一句话结论
|
||||
AI生图的"暗水印"分两层。**第1层(C2PA元数据)一行代码重编码就清除,根本不用PS;第2层(像素隐形水印)靠"转码+轻微像素扰动"自动化削弱**。两层都能在后端图像库(sharp/Pillow)里一道工序完成,全程不碰PS。
|
||||
|
||||
## 暗水印的真身(实测扒出来的)
|
||||
对刚生成的 `02_封面hook.png` 扒元数据,确认水印是 **C2PA**(Content Provenance 内容溯源标准):
|
||||
- PNG里有个 `caBX` 数据块,明文写着:
|
||||
- `OpenAI Media Service API` ← 谁生成的
|
||||
- `c2pa.watermarked` ← 它自己声明"我被加了水印"
|
||||
- `urn:c2pa:...` 唯一ID + 数字签名 + sha256哈希 ← 一整套"AI出生证明"
|
||||
|
||||
### 两层水印对照
|
||||
| 层 | 是什么 | 藏在哪 | 去除难度 |
|
||||
|---|---|---|---|
|
||||
| 第1层 C2PA元数据 | 明文"AI出生证明"(来源/时间/签名) | PNG的`caBX`块,**与像素无关** | 🟢 极易,重编码即掉 |
|
||||
| 第2层 SynthID像素水印 | 埋在像素里的隐形扰动,肉眼不可见 | 像素本身 | 🟡 较难,需重编码+扰动削弱 |
|
||||
|
||||
> 倩倩姐说的"PS转格式"= 干掉第1层(重新导出丢元数据)。系统里用sharp/Pillow重编码等价替代,不用PS。
|
||||
|
||||
## 实测结果
|
||||
### 第1层 C2PA:重编码即清除(已验证✅)
|
||||
```
|
||||
02_封面hook.png 2149880字节 C2PA: ❌还在(原图)
|
||||
test_cleaned.png 2013094字节 C2PA: ✅已清除(PNG重存)
|
||||
test_cleaned.jpg 301450字节 C2PA: ✅已清除(转JPG,顺带2MB→300KB)
|
||||
```
|
||||
结论:图像库读进来→重新save(),默认不保留元数据块,C2PA标识彻底消失。
|
||||
|
||||
### 第2层 像素水印:三种自动化手段(已验证可生成✅)
|
||||
1. **转JPG有损压缩(q90)** → 压缩天然扰动像素,最低成本,266KB
|
||||
2. **缩放重采样(缩2px再放回)** → 像素网格位移,水印对齐被破坏,278KB
|
||||
3. **裁边1px** → 改变像素坐标基准,297KB
|
||||
|
||||
三种单用或组合,后端一道工序完成,是"PS手工转格式"的自动化版本。
|
||||
|
||||
## 落到产品架构(重要)
|
||||
- **新增一道必经工序:出图后处理(post-process)**。位置在 worker 出图成功 → 存盘/返回前,强制过一遍"去水印"。
|
||||
- 工序内容:图像库重编码(清C2PA) + 转JPG/轻缩放(削像素水印) + 顺带压缩瘦身。
|
||||
- 这道工序**对客户透明**,客户拿到的就是干净可直发的图,不需要任何手动操作、不碰PS。
|
||||
- 技术栈:后端已选sharp(Node)或Pillow(Python),本就在依赖里,零新增成本。
|
||||
|
||||
## 诚实的边界(不吹)
|
||||
- 第1层C2PA:100%能去,已实测。
|
||||
- 第2层SynthID:是Google/OpenAI私有算法,**检测器不公开**,本地无法100%验证"是否彻底去干净"。行业现状:转码+重采样能显著削弱/破坏其鲁棒性,但没有任何方案敢保证100%。
|
||||
- **务实策略**:先上"重编码+转JPG+轻微重采样"这套标准工序(覆盖99%场景);真要较真,后期可加更强的像素扰动或第三方去水印库。这条不卡当前进度。
|
||||
|
||||
## 待办
|
||||
- [ ] 产品架构方案补一道"出图后处理/去水印"工序(worker出图→后处理→存盘)
|
||||
- [ ] 临时验证文件清理:output/test_cleaned.* output/clean_*.jpg
|
||||
@@ -1,57 +0,0 @@
|
||||
# Clover · 洞1 标杆链接读取 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC|性质:洞1(标杆链接能否读取)实测+代码勘察
|
||||
> 北哥真实标杆链接3条(采集表xlsx):
|
||||
> - xiaohongshu.com/explore/63db1c9b00000000020011e2
|
||||
> - xiaohongshu.com/explore/649b90240000000011012d8a
|
||||
> - xiaohongshu.com/explore/680ae7d3000000001c028a5b
|
||||
|
||||
## 一句话结论
|
||||
不是"链接读不了",是**裸抓读不了、登录态能读**。但凡"系统替客户去取数据"——哪怕只取一条——机制和批量爬虫一样、风险一样(封号+违反平台ToS)。真正干净的分界线 = 系统碰不碰小红书服务器。
|
||||
|
||||
## 实测1:裸HTTP抓取 = 死
|
||||
3条真实链接裸抓(无登录态、无签名),全部返回**完全相同的552780字节风控兜底假页**,标题"你访问的页面不见了",正文/赞藏评全被抽空。小红书反爬:必须 登录Cookie + 动态签名(x-s/x-t) 才给真数据。
|
||||
|
||||
## 实测2:本地已有一套跑通的"登录态采集"方案(金矿)
|
||||
倩倩姐n8n里有现成跑通链路,代码全在本地:
|
||||
- **工作流**:`企业培训项目/n8n/小红书仿写.json`(链接→读文案→读图片→二创→发布全链路)
|
||||
- 节点 `@donney521/n8n-nodes-xiaohongshu`:operation=basicInfo(读文案) / images(读图) / search(搜索)
|
||||
- **节点源码**:`/Users/qiyu/n8n-nodes-xiaohongshu/`(仅依赖axios,逻辑在 src/XhsClient.ts)
|
||||
- **签名服务**:`/Users/qiyu/xhs-sign-server/sign_server.py`(246行,Flask+Playwright,端口5005,当前未运行)
|
||||
|
||||
### 机制(透明了)
|
||||
```
|
||||
笔记链接 → XhsClient提取noteId
|
||||
→ 调签名服务(Playwright开隐身真浏览器,跑小红书自家JS函数 window._webmsxyw 算签名)
|
||||
→ 带 Cookie + x-s/x-t 请求 edith.xiaohongshu.com(小红书内部API)
|
||||
→ 拿到真数据:title/desc/likedCount/collectedCount/commentCount/images[]/tags[]/user
|
||||
```
|
||||
聪明点:不破解算法,而是让小红书**自己页面的JS**替我们算签名(stealth伪装绕webdriver检测)。算法它改、函数还是它自己的,照用。
|
||||
|
||||
### 三个脆弱点(诚实)
|
||||
1. 🟡 要养Cookie:登录态(a1+web_session)需扫码、会过期、要维护
|
||||
2. 🟡 签名服务是重资产:常驻Playwright无头浏览器,吃内存、要保活、会崩
|
||||
3. 🔴 **合规与封号**:走内部API+绕反爬=违反平台ToS,号可能被封,对外卖有合规隐患
|
||||
|
||||
## 关键判断:「输入链接→读取」也算自动扒
|
||||
- 区别只在"扒多少"(频率→被抓速度),不在"扒不扒"(性质→都违规、都可能封号)
|
||||
- 风险落在**提供登录态的那个账号**头上:我们的号→坑我们;客户的号→坑客户
|
||||
- 真正分界线 = **系统碰不碰小红书服务器**:
|
||||
- 系统替客户取(输入链接自动读) → 碰 → 算扒、有封号风险
|
||||
- 客户自己截图+粘文案 → 不碰 → 不算扒、零平台风险
|
||||
|
||||
## 洞1 最终结论:分场景三档(修正"一律降级截图")
|
||||
| 档 | 方案 | 拿数据 | 风险 | 定位 |
|
||||
|---|---|---|---|---|
|
||||
| 🔴 裸抓 | 纯链接抓 | ❌实测死 | — | 排除 |
|
||||
| 🟢 截图手填 | 客户截图+粘文案 | ✅ | 零平台风险 | **一期对外SaaS标配(安全底座)** |
|
||||
| 🟡 登录态采集 | 链接自动提取(这套n8n) | ✅全套 | 养号+签名服务+封号合规 | **北哥内部自用插件,二期可选模块,不进对外标配** |
|
||||
|
||||
### 决策依据
|
||||
1. Clover要卖给郑州一堆企业 → 对外标配不能内置"会让客户号被封"的功能 → 对外用🟢截图手填
|
||||
2. 北哥自己做内容、自担风险、用自己的号 → 可用🟡登录态采集帮自己扒标杆 → 北哥车间内部加速插件
|
||||
3. 这套代码现成跑通,真要做直接搬不重造(又一条"走过的对的路"),存档备用
|
||||
|
||||
## 待办
|
||||
- [ ] 架构方案:标杆采集对外=截图手填;登录态采集列为北哥内部/二期可选模块
|
||||
- [ ] (二期若做)签名服务+XhsClient打包进Clover北哥车间,作opt-in
|
||||
@@ -1,45 +0,0 @@
|
||||
# Clover · 洞2 北哥愿不愿用 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC+倩倩姐判断|性质:洞2(北哥愿不愿放下豆包用Clover)资料实测+倩倩姐拍板
|
||||
> 证据源:会议记录(产品业务推进会议.txt) + 北哥团队填写的采集表(xlsx) + 倩倩姐对北哥的直接了解
|
||||
|
||||
## 一句话结论
|
||||
"愿不愿放下豆包"是伪命题。真相:**北哥已看过Clover生成的图、基本满意,只剩字体细节要优化**——换工具的坎已迈过,剩下是产品打磨不是说服。
|
||||
|
||||
## 关键证据
|
||||
### 证据1:北哥的豆包只用在"写文案"一步,且不是痛点
|
||||
采集表里北哥团队填的真实流程(一条笔记1小时):
|
||||
| 步骤 | 工具 | 耗时 |
|
||||
|---|---|---|
|
||||
| 1定主题 | — | 5分 |
|
||||
| 2提卖点 | — | 10分 |
|
||||
| 3刷爆款截图 | 手动(小红书App) | — |
|
||||
| 4写文案 | **豆包** | 15分 |
|
||||
| 5配图 | — | 2分 |
|
||||
| 6、7 | — | **北哥标注:最花时间** |
|
||||
→ 最痛的是出图/配图(6、7),不是写文案。**Clover主攻出图,正中痛点。**
|
||||
|
||||
### 证据2:北哥已看过Clover的图,基本满意(倩倩姐确认)
|
||||
- 之前给北哥看过Clover生成的图,他**基本满意**,只提"一小部分字体"要优化
|
||||
- 字体问题=基线测试发现的"瓶身小字糊",后续细节优化(等北哥出图标准来迭代prompt)即可
|
||||
- → 洞2从"推测"升级为"已验证认可"
|
||||
|
||||
### 证据3:采集表"禁纯AI"红线 → 已可化解
|
||||
- 采集表模块C填了"必须实拍/禁纯AI",但这是认知低时写的(见progress认知1)
|
||||
- 化解牌:gpt-image-2带产品实拍图(image edit)=基于真实产品图再创作,非凭空AI画,还原度高
|
||||
- 北哥已看过图基本满意 = 这道心理坎实际已过
|
||||
|
||||
## 倩倩姐拍板的产品设计(定下来)
|
||||
1. **文案双轨**:Clover自带文案(背后接更强大模型,质量大概率优于豆包) + 支持导入外部文案(豆包等)
|
||||
- 豆包唯一可能优势=对中国用户语感理解,存疑不一定
|
||||
2. **客户可自调prompt**:文案和图,客户都能改提示词,不是黑盒(呼应基石约束A不写死 + 可插拔/自调整)
|
||||
3. **共存不强抢**:不喊"取代豆包",喊"帮你把最累的出图自动化";豆包想用还用,Clover无缝接
|
||||
|
||||
## 洞2 最终结论
|
||||
- 北哥用Clover概率:高(已看过图、基本满意)
|
||||
- 产品姿态:对着痛点(出图)说话,文案双轨+客户可调prompt+允许豆包共存
|
||||
- 遗留:字体优化(细节,等北哥标准迭代prompt) → 不阻塞
|
||||
|
||||
## 待办
|
||||
- [ ] 架构方案:文案双轨(自带+导入) / 客户可调prompt / 出图为主战场
|
||||
- [ ] (细节期)字体优化随北哥出图标准一起迭代
|
||||
@@ -1,39 +0,0 @@
|
||||
# Clover · 洞4 软件收费/商业模式 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC+倩倩姐拍板|性质:洞4(软件收费)商业判断(7洞最后一个)
|
||||
> 锚定约束:老板战略"TOKEN充值加进产品=业务闭环"(产品业务推进会议.txt) + 已拍板决策1(key隔离计费)
|
||||
|
||||
## 一句话结论
|
||||
MVP阶段软件免费(收费逻辑底层麻烦,后续单独讨论);token统一走我们自己的站url,但每账号自录自用各算各的key隔离不变。url收口到自己站(token生意闭环) + key隔离(成本清晰),两全。
|
||||
|
||||
## 收费两层(自助打印店比喻)
|
||||
| 层 | 收什么 | MVP定法 |
|
||||
|---|---|---|
|
||||
| 🟦耗材费(token) | AI生成消耗 | 客户付,买我们token站的token,走自己的key |
|
||||
| 🟩软件费 | 用Clover工具本身 | **MVP免费**,收费逻辑后续讨论(底层麻烦) |
|
||||
|
||||
## 倩倩姐拍板(定下来)
|
||||
1. **软件MVP免费**:收费底层麻烦,后续单独讨论,MVP先免费用、先把产品跑起来验证价值
|
||||
2. **token统一用我们自己站的url**:所有账号都连我们token站(不再兼容客户填任意中转站url)
|
||||
3. **key按人隔离自录自用**:
|
||||
- 小王登录→录小王的key(他在我们这买的/公司买的),小李登录→录小李的key
|
||||
- 不同key不同计费,各算各的(决策1保留不变)
|
||||
- 共同点:用的都是我们token站的url
|
||||
4. 设计精髓:**url统一=token业务收口到自己站(老板"卖token闭环"落地) + key隔离=每人成本清晰不混账**
|
||||
|
||||
## 对已拍板决策1的修正(小修)
|
||||
- 原决策1:"每账号绑自己token站key,按key单独算,key自录自用,禁止合并站级key混算;兼容填url+key"
|
||||
- 修正:**url收窄=统一用我们站(不再兼容任意中转站url)**;key自录自用+按key隔离计费**完全不变**
|
||||
- 新增:软件费MVP免费(收费推迟到产品成熟后单独讨论)
|
||||
|
||||
## 业内四模式备查(收费阶段再用)
|
||||
| 模式 | 说明 | 备注 |
|
||||
|---|---|---|
|
||||
| A订阅(按月/年) | 现金流稳 | 收费阶段候选 |
|
||||
| B按席位/量 | 匹配多租户+中央厨房 | 收费阶段候选(荐) |
|
||||
| C买断 | 违背"闭环"战略 | 排除 |
|
||||
| D token抽成 | key自录自用→平台无法抽成 | 排除(但卖自家token站token=另一种token变现,已采用) |
|
||||
|
||||
## 待办
|
||||
- [ ] MVP架构:软件免费(不做计费墙);token统一站url+按账号key隔离用量
|
||||
- [ ] (收费阶段)单独讨论软件收费底层逻辑,候选席位订阅
|
||||
@@ -1,49 +0,0 @@
|
||||
# Clover · 洞5 违禁词处理 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC实测+倩倩姐拍板|性质:洞5(违禁词软提示vs硬拦截)代码勘察+真实文案实测
|
||||
> 证据:产品包/worker/src/copy.js 现成违禁词逻辑 + 北哥采集表红线 + 北哥真实文案3.txt
|
||||
|
||||
## 一句话结论
|
||||
不是"软提示vs硬拦截"二选一,是**三级处理**(自动改/软提示/硬拦截)。产品包代码已验证这个思路,默认分级+北哥可调。
|
||||
|
||||
## 实测发现
|
||||
### 现成资产(产品包 copy.js)
|
||||
- 违禁词库:`BANNED_WORDS=[美白,祛斑,速效,医用,药妆]` `BANNED_VISUAL_WORDS=[前后对比,使用前后,变白,瑕疵消失,治疗前后...]`
|
||||
- 检测打分:扫文案命中违禁词→扣合规分→标注"含禁用词:xxx"(379-382行)
|
||||
- 自动改写 sanitizePlanningText(201-205行):前后对比→质地说明、变白→自然光泽感、瑕疵消失→妆感更服帖、治疗→日常使用
|
||||
|
||||
### 实测结果
|
||||
1. 北哥真实文案3.txt → 命中违禁词**0个**(北哥自己写得很合规,"仅用提亮/自然/伪素颜")
|
||||
2. 故意造违规文案测试,暴露关键点:
|
||||
- 检测全中(美白/祛斑/速效/变白/前后对比/医用/药妆一个没漏)
|
||||
- **但自动改写有边界**:sanitize只配了视觉类替换规则,`美白/祛斑/速效/医用/药妆`这5个功效词**只检测不改写**
|
||||
- 原因合理:视觉描述有等价合规说法可自动改;功效宣称词替换会扭曲原意,须人工改
|
||||
|
||||
## 洞5 结论:三级处理(机场安检三通道)
|
||||
| 级别 | 类型 | 处理 | 例 | 现成 |
|
||||
|---|---|---|---|---|
|
||||
| 🟢自动改 | 视觉描述类(有等价合规说法) | 自动改写,客户无感 | 前后对比→质地说明、变白→自然光泽感 | ✅已有sanitize,直接搬 |
|
||||
| 🟡软提示 | 功效宣称词(改了扭曲原意) | 标黄+建议词,客户自己改 | 美白→提亮?、祛斑→改善暗沉? | 🟡检测有,补提示UI |
|
||||
| 🔴硬拦截 | 高危词(发了必被封) | 必须改才能发 | 医用、药妆(医疗级宣称) | 🔴需新增 |
|
||||
|
||||
### 为什么三级(不能纯软/纯硬)
|
||||
- 纯软提示:医用/药妆这类发出去直接限流封号,只标黄不拦,客户手滑就出事 → 须兜底
|
||||
- 纯硬拦截:美白在"提亮肤色"语境可合规,一刀切误杀烦客户
|
||||
- 自动改:视觉类有现成等价说法,自动改体验最好(产品包已做)
|
||||
|
||||
## 倩倩姐拍板(定下来)
|
||||
1. **三级处理** ✅ 采纳
|
||||
2. **默认分级 + 北哥可调** ✅ 一期就做可调分级
|
||||
3. 违禁词库可配置可更新(基石A不写死),平台规则变能后台改
|
||||
4. 双保险:生成时prompt就规避(产品包prompt已做) + 发布前检查
|
||||
5. 图片视觉违禁(前后对比图):文本扫不到→靠prompt约束模型不生成(基线已验证守住)+发布前人工眼检
|
||||
|
||||
### 默认分级建议(北哥标准来了可调)
|
||||
- 🔴硬拦截:医用、药妆、特效、根治(医疗级宣称,封号高危)
|
||||
- 🟡软提示:美白、祛斑、速效(建议词:提亮/改善暗沉/快速见效)
|
||||
- 🟢自动改:前后对比、变白、瑕疵消失、治疗前后(视觉类,有等价说法)
|
||||
> 具体归级待北哥合规标准细化(采集表只填了禁5词,没填严重程度分级)
|
||||
|
||||
## 待办
|
||||
- [ ] 架构方案:违禁词三级处理模块(词库可配置+分级可调);自动改逻辑搬产品包sanitize
|
||||
- [ ] (采集补)向北哥要违禁词严重程度分级
|
||||
@@ -1,56 +0,0 @@
|
||||
# Clover · 洞6 发布最后一公里 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC实测+倩倩姐拍板|性质:洞6(发布最后一公里)代码勘察+定位修正
|
||||
> ⚠️ 本洞修正了产品出口的根本定位,重要
|
||||
|
||||
## 一句话结论
|
||||
发布出口不是"发到小红书",是**产出达人能直接用的素材交付包**。因为北哥团队=素材生产方(中央厨房),真正发布的是一堆分散的达人。系统自动发从需求上就不成立。
|
||||
|
||||
## 定位修正(最关键认知)
|
||||
- 北哥团队 = **素材生产方(中央厨房)**:批量生产种草素材
|
||||
- 达人 = **素材发布方(分发渠道)**:拿素材包自己发,分散、不懂上下文
|
||||
- Clover出口 = **"达人拿了就能照着发"的结构化素材交付包**
|
||||
- → 这解释了洞1洞6为何都该手动:真正发布的人是分散的达人,不可能自动登录一堆达人的号
|
||||
- → "不能变形/命名好/分类/清单"不是锦上添花,是**刚需**:素材流转到第三方手里,全靠素材包自解释,命名乱/图变形/配错文案=达人发错或不会发
|
||||
|
||||
## 代码勘察(发布自动化的现成方案=不采用)
|
||||
- n8n仿写工作流有发布节点 `n8n-nodes-douyin.xiaohongshu` operation=publish
|
||||
- 机制:调第三方/自建发布中台(Domain默认localhost:3000),getQrCode扫码托管账号→/api/note/publish代发
|
||||
- 凭证=API Key+Domain,是又一层"托管账号代发"中间件,非小红书官方
|
||||
- 🔴 风险最高:代发=机器发布特征(营销号典型),封号风险比读取(洞1)更大;还多一层第三方托管账号
|
||||
- 结论:存档,不采用(且需求上达人发布也用不上自动发)
|
||||
|
||||
## 洞6 结论:三档(对外走素材包交付)
|
||||
| 档 | 做法 | 风险 | 定位 |
|
||||
|---|---|---|---|
|
||||
| 🔴 全自动发布 | 系统直接发小红书 | 最高(封号) | 对外不做+需求不成立(达人分散) |
|
||||
| 🟢 素材包交付 | 打包好图文,达人手动发 | 零平台风险 | **对外标配** |
|
||||
| 🟡 官方蒲公英/聚光 | 合规代发 | 需企业资质 | 二期看北哥资质 |
|
||||
|
||||
## 倩倩姐拍板:达人素材交付包(定下来)
|
||||
做好素材打包、分类、不变形、命名规范、发布清单。手动下载到一个地方,人工(达人)去发。
|
||||
|
||||
### 交付包结构
|
||||
```
|
||||
某产品某主题_素材包/
|
||||
├── 📋 发布清单.txt ← 达人照做:发几条/每条用哪些图/什么文案/什么标签
|
||||
├── 笔记1_封面hook/
|
||||
│ ├── 01_封面图.jpg ← 命名带顺序,达人不会传错序
|
||||
│ ├── 02_质感图.jpg
|
||||
│ ├── 03_软转化图.jpg
|
||||
│ └── 文案.txt ← 标题+正文+标签,达人直接复制
|
||||
├── 笔记2_xxx/
|
||||
└── ✅ 合规说明.txt ← 违禁词已过/图已去水印,达人放心发
|
||||
```
|
||||
|
||||
### 设计要点(把导出做到不像降级)
|
||||
1. **图不变形**:去水印后处理时严守尺寸比例(3:4),不挤压拉伸
|
||||
2. **命名规范**:序号前缀(01/02/03)保证达人按序传图
|
||||
3. **分类清晰**:按笔记分文件夹,一条笔记的图文聚在一起
|
||||
4. **发布清单**:达人不懂上下文,清单要能自解释(发几条、配图顺序、文案、标签)
|
||||
5. **合规说明**:附"违禁词已过/已去水印",让达人放心发
|
||||
|
||||
## 待办
|
||||
- [ ] 架构方案:出口=素材交付包生成器(打包+分类+命名+清单+合规说明)
|
||||
- [ ] 数据模型考虑"达人"角色/素材包流转(可能影响表设计)
|
||||
- [ ] 自动发布(n8n方案)存档,二期看官方蒲公英资质
|
||||
@@ -1,38 +0,0 @@
|
||||
# Clover · 洞7 数据归属/导出 验证落盘
|
||||
|
||||
> 创建:2026-06-04|验证人:CC实测+倩倩姐拍板|性质:洞7(数据归属/导出)代码勘察+商业判断
|
||||
> 与洞6"达人交付"强联动
|
||||
|
||||
## 一句话结论
|
||||
数据分三类按层归属:原始数据(输入+产出)100%归客户、可导出不绑架;飞轮学到的偏好归平台、作为服务能力。这是C方案(分层归属),倩倩姐已拍板。
|
||||
|
||||
## 代码勘察(banana地基现状)
|
||||
- 归属:所有表有`user_id`(按用户归属),但**无workspace_id**(没团队/多租户隔离) → 印证架构方案"全表加workspace_id"决策,地基只到个人级
|
||||
- 导出:banana**完全没有导出功能** → 🔴自造项,需新建
|
||||
|
||||
## 三类数据归属
|
||||
| 类型 | 例 | 归谁 | 导出 | 隔离 |
|
||||
|---|---|---|---|---|
|
||||
| 🟦客户输入物 | 产品图/卖点/标杆截图/自调prompt | 客户 | ✅必须能导 | workspace隔离 |
|
||||
| 🟩客户产出物 | 文案/图/素材包 | 客户 | ✅必须能导(本就要交付达人) | workspace隔离 |
|
||||
| 🟨飞轮偏好 | 调教手感(8选3记录/审核理由/学到的偏好) | **平台** | 不随客户走 | 最敏感 |
|
||||
|
||||
## 倩倩姐拍板(C方案,定下来)
|
||||
1. **原始数据(输入+产出+素材包) → 100%归客户,随时导出,不绑架** = 对外卖的信任基础,必须承诺
|
||||
2. **飞轮学到的偏好 → 归平台**,作为"越用越懂你"的服务能力,客户走了不带走
|
||||
- 类比:健身房——你的体测数据是你的(可带走),教练攒下的带人经验是健身房的(带不走)
|
||||
3. **可导出一期就做**(洞6素材包交付本来就是导出,一套逻辑)
|
||||
4. **飞轮归属写进给客户的协议/说明**讲明(法务表述可能要老板拍)
|
||||
|
||||
## 叠加洞6达人交付的特殊点
|
||||
- 素材包交付给外部达人后=数据已离开Clover,属"客户主动分发",平台不担泄露责任(协议写明客户对其分发负责)
|
||||
- 达人**不进Clover系统**(只收素材包)→ 不涉及给达人开账号/达人数据隔离 → 简化架构
|
||||
|
||||
## 对地基的影响
|
||||
- 全表加 workspace_id(架构方案已定) = 数据归属的技术底座,实现客户间隔离
|
||||
- 新建导出/素材包生成功能(banana没有,🔴自造) = 兑现"可导出"+洞6出口,一套逻辑
|
||||
- 偏好数据表标记归属层级(原始data vs 学习成果),为C方案留技术口子
|
||||
|
||||
## 待办
|
||||
- [ ] 架构方案:全表workspace_id隔离 + 导出/素材包生成器 + 偏好表标归属层级
|
||||
- [ ] 协议/客户说明:写明"原始数据可导出归客户 / 飞轮偏好归平台"(法务表述待老板拍)
|
||||
Reference in New Issue
Block a user