Procházet zdrojové kódy

feat(classify+web):存下提取的创作知识点,详情页可看;图片点击放大

- store:post_class 加 knowledge/points 列(带旧库迁移),class_map 一并返回。
- classify:图文存 extract.txt 提取的全文 text + 按卡片 points;视频 classify_video.txt
  加 knowledge 输出(创作帖把视频里教的方法分条提炼)。三平台重跑填充。
- 前端:创作卡片显示「📚 提取的创作知识点」(可折叠);小红书/微信图片点击放大看大图
  (原帖被反爬封,直接看本地大图)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SamLee před 2 týdny
rodič
revize
e2f0cd1b35

+ 14 - 9
acquisition/classify.py

@@ -72,10 +72,12 @@ def _compress(mp4: Path) -> Path:
 
 
 def classify_imgtext(p: dict, extractor: GeminiExtractor) -> tuple:
-    ec = extractor.extract(_imgtext_post(p))           # 完整 extract.txt → is_empty
+    ec = extractor.extract(_imgtext_post(p))           # 完整 extract.txt → is_empty + 提取的知识
     if ec.is_empty:
-        return 0, "判为非创作:无可迁移的创作方法"
-    return 1, (ec.text or "")[:60]
+        return 0, "判为非创作:无可迁移的创作方法", "", ""
+    points = json.dumps([{"index": c.index, "content": c.content} for c in (ec.cards or [])],
+                        ensure_ascii=False)
+    return 1, (ec.text or "")[:60], (ec.text or ""), points   # reason 取开头,knowledge 存全文
 
 
 def classify_video(p: dict, settings: Settings) -> tuple:
@@ -84,7 +86,7 @@ def classify_video(p: dict, settings: Settings) -> tuple:
     rel = p.get("video") or ""
     mp4 = ROOT / rel.lstrip("/")
     if not rel or not mp4.exists():
-        return None, "无本地视频"
+        return None, "无本地视频", "", ""
     use = _compress(mp4) if mp4.stat().st_size > COMPRESS_OVER_MB * 1048576 else mp4
     try:
         media = "data:video/mp4;base64," + base64.b64encode(use.read_bytes()).decode()
@@ -108,19 +110,22 @@ def classify_video(p: dict, settings: Settings) -> tuple:
             if resp.status_code == 200:
                 d = json.loads(resp.json()["choices"][0]["message"]["content"])
                 is_empty = bool(d.get("is_empty"))
-                return (0 if is_empty else 1), str(d.get("reason", ""))[:60]
+                if is_empty:
+                    return 0, str(d.get("reason", ""))[:60], "", ""
+                kn = str(d.get("knowledge", "") or "")
+                return 1, str(d.get("reason", ""))[:60], kn, ""
             last = f"http {resp.status_code}"
         except Exception as exc:
             last = str(exc)[:50]
         time.sleep(2 * (attempt + 1))
-    return None, f"判定失败: {last}"
+    return None, f"判定失败: {last}", "", ""
 
 
 def _safe(fn, *a) -> tuple:
     try:
         return fn(*a)
     except Exception as exc:
-        return None, f"判定失败: {str(exc)[:60]}"
+        return None, f"判定失败: {str(exc)[:60]}", "", ""
 
 
 def main() -> None:
@@ -137,11 +142,11 @@ def main() -> None:
     done = {"n": 0, "fail": 0}
 
     def _write(p, res):
-        ic, reason = res
+        ic, reason, knowledge, points = res
         if ic is None:
             done["fail"] += 1
         else:
-            store.upsert_class(conn, p["url"], ic, reason, ts)
+            store.upsert_class(conn, p["url"], ic, reason, ts, knowledge, points)
         done["n"] += 1
         if done["n"] % 20 == 0:
             print(f"  {done['n']}/{total}(失败 {done['fail']})")

+ 28 - 6
acquisition/store.py

@@ -59,10 +59,18 @@ CREATE TABLE IF NOT EXISTS post_class (
     url       TEXT PRIMARY KEY,
     is_creation INTEGER,              -- 1=创作知识 / 0=非
     reason    TEXT,
+    knowledge TEXT,                   -- 提取出的完整创作知识(创作帖才有)
+    points    TEXT,                   -- 结构化知识点 json(按卡片/分段)
     created_at INTEGER NOT NULL
 );
 """
 
+# 旧库迁移:post_class 早期没有 knowledge/points 列,补上(已存在则忽略)
+_MIGRATIONS = [
+    "ALTER TABLE post_class ADD COLUMN knowledge TEXT",
+    "ALTER TABLE post_class ADD COLUMN points TEXT",
+]
+
 
 def connect(db_path: Path | str = DB_PATH) -> sqlite3.Connection:
     """打开(必要时建)库,建表,行按 dict 取。busy_timeout 让多进程并发写不报锁。"""
@@ -72,6 +80,11 @@ def connect(db_path: Path | str = DB_PATH) -> sqlite3.Connection:
     conn.row_factory = sqlite3.Row
     conn.execute("PRAGMA busy_timeout=30000")   # 撞锁等 30s 而非报错(与微信补下载并发安全)
     conn.executescript(_SCHEMA)
+    for sql in _MIGRATIONS:
+        try:
+            conn.execute(sql)
+        except sqlite3.OperationalError:
+            pass                                 # 列已存在
     return conn
 
 
@@ -221,9 +234,11 @@ def get_unclassified_posts(conn: sqlite3.Connection) -> list[dict]:
     return out
 
 
-def upsert_class(conn: sqlite3.Connection, url: str, is_creation: int, reason: str, ts: int) -> None:
-    conn.execute("INSERT OR REPLACE INTO post_class(url, is_creation, reason, created_at) VALUES(?,?,?,?)",
-                 (url, is_creation, reason, ts))
+def upsert_class(conn: sqlite3.Connection, url: str, is_creation: int, reason: str, ts: int,
+                 knowledge: str = "", points: str = "") -> None:
+    conn.execute(
+        "INSERT OR REPLACE INTO post_class(url, is_creation, reason, knowledge, points, created_at) "
+        "VALUES(?,?,?,?,?,?)", (url, is_creation, reason, knowledge, points, ts))
     conn.commit()
 
 
@@ -245,13 +260,20 @@ def posts_to_classify(conn: sqlite3.Connection, platforms: list[str]) -> list[di
 
 
 def class_map(conn: sqlite3.Connection, urls) -> dict:
-    """{url: {is_creation, reason}},给取数时挂分类。"""
+    """{url: {is_creation, reason, knowledge, points}},给取数时挂分类 + 提取的知识点。"""
     urls = [u for u in urls if u]
     if not urls:
         return {}
     qs = ",".join("?" * len(urls))
-    rows = conn.execute(f"SELECT url, is_creation, reason FROM post_class WHERE url IN ({qs})", urls).fetchall()
-    return {r["url"]: {"is_creation": r["is_creation"], "reason": r["reason"]} for r in rows}
+    rows = conn.execute(
+        f"SELECT url, is_creation, reason, knowledge, points FROM post_class WHERE url IN ({qs})", urls
+    ).fetchall()
+    out = {}
+    for r in rows:
+        out[r["url"]] = {"is_creation": r["is_creation"], "reason": r["reason"],
+                         "knowledge": r["knowledge"] or "",
+                         "points": json.loads(r["points"]) if r["points"] else []}
+    return out
 
 
 def class_counts(conn: sqlite3.Connection) -> dict:

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
acquisition/web/app/dist/assets/index-0mMR3Azo.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
acquisition/web/app/dist/assets/index-DlEf_eIX.css


+ 2 - 2
acquisition/web/app/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>创作知识 · 找帖子</title>
-    <script type="module" crossorigin src="/app/assets/index-Dv59HUIk.js"></script>
-    <link rel="stylesheet" crossorigin href="/app/assets/index-AlUlj_mH.css">
+    <script type="module" crossorigin src="/app/assets/index-0mMR3Azo.js"></script>
+    <link rel="stylesheet" crossorigin href="/app/assets/index-DlEf_eIX.css">
   </head>
   <body>
     <div id="root"></div>

+ 25 - 5
acquisition/web/app/src/pages/QueryDetail.jsx

@@ -12,6 +12,7 @@ const PLATFORMS = [
 export default function QueryDetail({ query }) {
   const [data, setData] = useState({ total: 0, items: [] })
   const [showPrompts, setShowPrompts] = useState(false)
+  const [zoom, setZoom] = useState(null)   // 点开的大图(小红书/微信原帖被反爬封,直接看本地大图)
   useEffect(() => { fetchSearch({ query, size: 100 }).then(setData) }, [query])
 
   const hits = data.items.filter((r) => r.ok)
@@ -28,6 +29,12 @@ export default function QueryDetail({ query }) {
         <span className="cls no" style={{ margin: '0 4px' }}>非创作知识</span> 由模型读真实内容判定</div>
 
       {showPrompts && <JudgePromptModal onClose={() => setShowPrompts(false)} />}
+      {zoom && (
+        <div className="lightbox" onClick={() => setZoom(null)}>
+          <img src={zoom} alt="" onClick={(e) => e.stopPropagation()} />
+          <span className="lbx" onClick={() => setZoom(null)}>✕</span>
+        </div>
+      )}
 
       {hits.length === 0 ? (
         <div className="empty">这条 query 没搜到带媒体的结果。</div>
@@ -47,13 +54,13 @@ export default function QueryDetail({ query }) {
                   {creation.length > 0 && (
                     <>
                       <div className="subhd">创作知识 · {creation.length}</div>
-                      <div className="grid">{creation.map((r) => <Result key={r.id} r={r} cls={p.cls} />)}</div>
+                      <div className="grid">{creation.map((r) => <Result key={r.id} r={r} cls={p.cls} onZoom={setZoom} />)}</div>
                     </>
                   )}
                   {others.length > 0 && (
                     <>
                       <div className="subhd dim">非创作知识 · {others.length}</div>
-                      <div className="grid">{others.map((r) => <Result key={r.id} r={r} cls={p.cls} />)}</div>
+                      <div className="grid">{others.map((r) => <Result key={r.id} r={r} cls={p.cls} onZoom={setZoom} />)}</div>
                     </>
                   )}
                 </>
@@ -100,18 +107,20 @@ function ClsBadge({ cls }) {
   </span>
 }
 
-function Result({ r, cls }) {
+function Result({ r, cls, onZoom }) {
   const imgs = (r.extra?.images && r.extra.images.length ? r.extra.images : (r.cover ? [r.cover] : []))
   return (
     <div className={`lane ${cls}`}>
       {r.video
         ? <video className="sv" src={r.video} controls preload="metadata" poster={r.cover || undefined} />
         : imgs.length > 1
-          ? <div className="gallery">{imgs.map((u, i) => <img key={i} className="gimg" src={u} alt="" />)}</div>
-          : imgs[0] && <img className="cover" src={imgs[0]} alt="" />}
+          ? <div className="gallery">{imgs.map((u, i) => (
+              <img key={i} className="gimg" src={u} alt="" onClick={() => onZoom(u)} />))}</div>
+          : imgs[0] && <img className="cover" src={imgs[0]} alt="" onClick={() => onZoom(imgs[0])} />}
       <ClsBadge cls={r.cls} />
       <div className="t">{r.title || '(无标题)'}</div>
       {r.extra?.body_text && <div className="bt">{r.extra.body_text}</div>}
+      {r.cls?.is_creation === 1 && r.cls?.knowledge && <Knowledge text={r.cls.knowledge} />}
       <div className="meta">
         {imgs.length > 1 ? `${imgs.length} 图 · ` : ''}
         {r.extra?.nick ? `${r.extra.nick} · ` : ''}
@@ -120,3 +129,14 @@ function Result({ r, cls }) {
     </div>
   )
 }
+
+// 提取出的创作知识点(可折叠,默认展开)
+function Knowledge({ text }) {
+  const [open, setOpen] = useState(true)
+  return (
+    <div className="kn">
+      <div className="knhd" onClick={() => setOpen(!open)}>📚 提取的创作知识点 <span className="kct">{open ? '收起 ▲' : '展开 ▼'}</span></div>
+      {open && <div className="knbody">{text}</div>}
+    </div>
+  )
+}

+ 15 - 1
acquisition/web/app/src/styles.css

@@ -114,7 +114,21 @@ td.q { font-weight: 600; }
 .cls.none { background: #f0f0f3; color: var(--muted); font-weight: 600; }
 /* 小红书完整帖:图片画廊 + 正文(链接会被反爬封,整帖存本地) */
 .gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
-.gallery .gimg { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: #f0f0f3; }
+.gallery .gimg { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: #f0f0f3; cursor: zoom-in; }
+.lane .cover { cursor: zoom-in; }
+
+/* 提取的创作知识点 */
+.kn { margin: 8px 0 4px; border: 1px solid var(--accent-soft); border-radius: 8px; overflow: hidden; }
+.knhd { background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700;
+  padding: 5px 9px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
+.knhd .kct { font-weight: 600; font-size: 11px; }
+.knbody { padding: 8px 10px; font-size: 12px; line-height: 1.65; color: var(--ink); white-space: pre-wrap; }
+
+/* 点击放大大图(小红书原帖被反爬封,直接看本地大图) */
+.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.86); z-index: 60;
+  display: flex; align-items: center; justify-content: center; padding: 24px; cursor: zoom-out; }
+.lightbox img { max-width: 96vw; max-height: 94vh; object-fit: contain; border-radius: 6px; cursor: default; }
+.lightbox .lbx { position: fixed; top: 16px; right: 22px; color: #fff; font-size: 26px; cursor: pointer; }
 .lane .bt { font-size: 11.5px; color: #555; line-height: 1.5; margin: 6px 0 4px; max-height: 78px;
   overflow: hidden; white-space: pre-wrap; }
 .lane .meta { color: var(--muted); font-size: 11.5px; }

+ 6 - 1
prompts/classify_video.txt

@@ -25,4 +25,9 @@
 - 通篇只是讲观点 / 讲道理 / 讲某话题 / 展示作品 / 抒情励志 / 分享经历,没有教"怎么做内容"的方法 → is_empty=true(不算)。
 - "范围内但拿不准有没有方法" → is_empty=false;但"到底是讲观点还是教创作"要**果断**——明显是在讲观点 / 作品本身,就判 true,别用"拿不准"放进来。
 
-只输出一个 JSON 对象:{"is_empty": true/false, "reason": "一句话理由,点明是「讲观点/作品本身」还是「教了哪种可复用的创作方法」(≤30字)"}
+若 is_empty=false(算创作知识),还要把视频里教的**具体创作知识点**完整提炼出来(忠实视频内容、不编造、不拔高;分条列清:教了什么方法/原理/清单,按视频讲解顺序)。
+
+只输出一个 JSON 对象:
+{"is_empty": true/false,
+ "reason": "一句话理由,点明是「讲观点/作品本身」还是「教了哪种可复用的创作方法」(≤30字)",
+ "knowledge": "is_empty=false 时:视频里教的具体创作知识点全文(分条);is_empty=true 时:空字符串"}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů