main
reng 4 weeks ago
parent 4093e6b056
commit 0e96e16921
  1. 3
      v2/app/src/App.css
  2. 20
      v2/app/src/App.jsx
  3. 56
      v2/app/src/components/point.jsx
  4. 13
      v2/app/src/utils/parsing.js
  5. 163
      v2/scrapper/agent.js
  6. 58
      v2/scrapper/assets/agent_v2.txt
  7. 4
      v2/scrapper/embeddings.js
  8. 23
      v2/scrapper/main.js
  9. 8
      v2/scrapper/scrapper.js

@ -1,5 +1,8 @@
@import "tailwindcss";
body{
font-family: monospace;
}
button, input, select{
@apply border rounded-full p-2;
}

@ -114,6 +114,10 @@ function App() {
const selectedKeywords = Array.from(document.querySelectorAll('input[name="keyword"]:checked')).map(el=>el.value);
console.log("Selected keywords:", selectedKeywords);
if(selectedKeywords.length===0){
return;
}
// keep 4 max
const limitedKeywords = selectedKeywords.slice(0,4);
const limit = parseInt(document.querySelector('input[name="numResults"]').value) || 50;
@ -142,12 +146,23 @@ function App() {
</div>
{selectTab==='text' && (
<div id="search-results">
<div className="border-b p-2 my-2 grid grid-cols-6 gap-2 font-bold">
<div>#</div>
<div>Score</div>
<div>Order</div>
<div>Teaser</div>
<div className="col-span-2">Content</div>
<div className="bg-pink-200 text-xs self-center">Summary</div>
<div className="flex flex-row gap-2 flex-wrap items-center text-xs">Keywords</div>
</div>
{results?.length>0 && results.map((item, index)=>{
let output={};
return (
<div key={index} className="border-b p-2 my-2 grid grid-cols-5 gap-2">
{/* <p>score={item.score.toFixed(4)}</p> */}
<div key={index} className="border-b p-2 my-2 grid grid-cols-6 gap-2">
<div>#{index+1}</div>
<p>score={item.score.toFixed(4)}</p>
{(()=>{
let p={
...item.payload,
@ -155,6 +170,7 @@ function App() {
};
return (<>
<div>{p.number}/{p.total}</div>
<div className="">{p.teaser}</div>
<div className="col-span-2">{p.metadata.text}</div>
<div className="bg-pink-200 text-xs self-center">{p.summry}</div>
<div className="flex flex-row gap-2 flex-wrap items-center text-xs">{p.keywords.map(el=><span className="bg-gray-200 rounded-full px-2" key={el}>{el}</span>)}</div>

@ -3,15 +3,17 @@ import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { useFrame, useThree } from '@react-three/fiber';
import gsap from 'gsap';
import { normalize } from 'umap-js/dist/matrix';
import { SplitText } from 'gsap/SplitText';
gsap.registerPlugin(SplitText) ;
export const PointScale=200;
const PointSize=1;
const PointDuration=3;
const PointDuration=5;
const KeywordOffset=0;
const SceneDuration=10; //
const SceneDuration=0.5; //
const DEPTH_CONFIG = {
NEAR: 1200, //
@ -97,7 +99,7 @@ export default function Point({point, index, totalPoints, result, showContent, s
// alpha (0 1)
let alpha = 1 - (distance - DEPTH_CONFIG.NEAR) / (DEPTH_CONFIG.FAR - DEPTH_CONFIG.NEAR);
alpha = THREE.MathUtils.clamp(alpha, 0, 1);
alpha = THREE.MathUtils.clamp(alpha, 0.2, 1);
// 調 ()
const depthScaleFactor = THREE.MathUtils.clamp(1.5 - (distance / 300), 0.5, 1.2);
@ -114,7 +116,7 @@ export default function Point({point, index, totalPoints, result, showContent, s
refText.current.style.filter = `blur(${Math.min(blurAmount, 4)}px)`;
//
refText.current.style.display = alpha <= 0.01 ? 'none' : 'block';
// refText.current.style.display = alpha <= 0.01 ? 'none' : 'block';
}
});
function setTextStyle(t){
@ -129,7 +131,7 @@ export default function Point({point, index, totalPoints, result, showContent, s
if(!payload) return;
const lifeTime=payload.normalized_time || 0;
// console.log("Point lifeTime:", lifeTime);
console.log("Point lifeTime:", lifeTime);
// animate point size based on lifeTime
const targetSize=1;
@ -146,17 +148,20 @@ export default function Point({point, index, totalPoints, result, showContent, s
duration: PointDuration,
ease: 'power1.inOut',
onComplete: ()=>{
console.log("Show text for point:", index);
// console.log("Show text for point:", index);
},
onUpdate: ()=>{
setTextStyle(refAnimation.current.t);
}
}); // lifeTime
timeline.to(refAnimation.current,{
t: 0,
duration: PointDuration,
onComplete: ()=>{
console.log("Hide text for point:", index);
// console.log("Hide text for point:", index);
},
onUpdate: ()=>{
setTextStyle(refAnimation.current.t);
@ -174,6 +179,35 @@ export default function Point({point, index, totalPoints, result, showContent, s
},[payload]);
useEffect(()=>{
if(!showContent) return;
if(!refText.current) return;
// const tosplit=refText.current.getElementsByClassName('splittext')[0];
// if(!tosplit) return;
// let split=SplitText.create(tosplit, {type: "words, chars"});
// console.log("SplitText chars:", split.chars.length);
// gsap.fromTo(split.chars, {
// // opacity: 0,
// y: 50,
// stagger: 0.05
// }, {
// // opacity: 1,
// y: -50,
// duration: 0.5,
// ease: 'power2.inOut',
// repeat:-1,
// });
// return ()=>{
// // split.revert();
// gsap.killTweensOf(split.chars);
// };
},[showContent])
useEffect(()=>{
setPayload({
@ -199,11 +233,11 @@ export default function Point({point, index, totalPoints, result, showContent, s
/>
<Html>
<div ref={refText} className='text-white p-2 select-none text-center opacity-0'>
{showKeyword && <div className='flex flex-row justify-center flex-wrap text-[1rem]'>
{<div className={`flex flex-row justify-center flex-wrap text-[1rem] ${showKeyword ? 'block' : 'hidden'}`}>
{payload?.keywords.map((el, index)=><span key={index} className='px-2' style={{transform: `translate(${Math.random() * KeywordOffset}px,${Math.random() * KeywordOffset}px)`}}>{el}</span>)}
</div>}
{showContent && <div
className='text-[2rem] text-white rounded p-2 text-center w-[100vw]'
{<div
className={`text-[2rem] text-white rounded p-2 text-center w-[50vw] splittext ${showContent ? 'block' : 'hidden'}`}
// style={{width: `${Math.random()*(payload?.content.length/5 || 10) + 20}vw`}}
dangerouslySetInnerHTML={{__html: getContent()}}
></div>}

@ -140,7 +140,7 @@ export async function searchByKeywords(keywordIds, limit){
// get keyword embeddings from Qdrant
let allResults = [];
for(const keywordId of keywordIds){
const res=await fetch(`http://localhost:6333/collections/${COLLECTION_KEYWORD}/points/${keywordId}`, {
const res=await fetch(`http://localhost:6333/collections/${COLLECTION_KEYWORD}/points/${parseInt(keywordId)-1}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -202,9 +202,16 @@ function normalizeResultTime(results){
console.log("Time range:", min, max);
results.forEach(item=>{
// results sort by time
results.sort((a, b) => {
const timeA = JSON.parse(a.payload.metadata).published_on;
const timeB = JSON.parse(b.payload.metadata).published_on;
return timeA - timeB;
});
results.forEach((item, index)=>{
const time = JSON.parse(item.payload.metadata).published_on;
item.payload.normalized_time = (time - min) / (max - min);
item.payload.normalized_time = index;//Math.floor((time - min) / (max - min)*results.length);
});
return results;

@ -2,48 +2,67 @@ import { readFileSync } from "fs";
export async function jsonToAgent(filePath, agentPromptFile='agent.txt') {
let system_prompt;
const res = readFileSync(`./assets/${agentPromptFile}`);
if (res) {
system_prompt = res.toString();
// console.log(system_prompt);
} else {
console.error("Failed to load system prompt");
return;
}
console.log('processing file:', filePath);
let content;
let simple_content;
const fileRes = readFileSync(filePath);
if (fileRes) {
content = fileRes.toString();
const filecontent = fileRes.toString();
// simplify content
let jsonData;
try {
jsonData = JSON.parse(filecontent);
} catch (err) {
console.error("Failed to parse JSON file:", err);
return;
}
content = jsonData;
let simplify = { input: [] };
simplify.input.push(jsonData.thread.text);
jsonData.replies.forEach(element => {
simplify.input.push(element.text);
});
simple_content = JSON.stringify(simplify);
// console.log(simple_content);
} else {
console.error("Failed to load JSON file");
return;
}
// return;
console.log('sending to agent...', content.replies.length + 1);
// 使用 OpenAI Chat Completions API 搭配 Structured Outputs (json_schema)
const response = await fetch('https://api.openai.com/v1/chat/completions', {
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: "gpt-4o",
messages: [
{ role: "system", content: system_prompt },
{ role: "user", content: content }
],
// model: "gpt-4.1-mini",
// input: [
// { role: "system", content: system_prompt.replaceAll("{{N}}", content.replies.length + 1) },
// { role: "user", content: simple_content }
// ],
prompt:{
id: "pmpt_69607acd9c10819696e7a25fd66cdd6b016fc7e2ef2c3ca5",
// version: "4",
variables:{
count: (content.replies.length + 1).toString(),
content: simple_content
}
},
store: false,
response_format: {
text: {
format:{
type: "json_schema",
json_schema: {
name: "conversation_analysis",
strict: true,
schema: {
@ -70,7 +89,7 @@ export async function jsonToAgent(filePath, agentPromptFile='agent.txt') {
},
total: {
type: "integer",
description: "留言總數 M"
description: "留言總數 count"
},
content: {
type: "string",
@ -79,26 +98,9 @@ export async function jsonToAgent(filePath, agentPromptFile='agent.txt') {
teaser:{
type: "string",
description: "10字內具備共鳴的核心情緒"
},
metadata: {
type: "object",
description: "原始留言的後設資料",
properties:{
text: { type:'string'},
published_on: { type:'integer'},
like_count: { type:'integer'},
reply_count: { type:'integer'},
username: { type:'string'},
id: { type:'string'},
code: { type:'string'},
parent_post_id: { type:'string'}
},
required: ["text", "published_on", "like_count", "reply_count", "username", "id", "code", "parent_post_id"],
// 在 strict: true 模式下,這必須為 false
additionalProperties: false,
}
},
required: ["summry", "keywords", "number", "total", "content", "metadata", "teaser"],
required: ["summry", "keywords", "number", "total", "content", "teaser"],
additionalProperties: false
}
}
@ -107,24 +109,99 @@ export async function jsonToAgent(filePath, agentPromptFile='agent.txt') {
additionalProperties: false
}
}
},
temperature: 0.1
}
})
});
const data = await response.json();
if (data.error) {
console.error("API Error:", data.error);
return null;
}
try {
// 從 choices[0].message.content 中解析 AI 回傳的 JSON 字串
const output = JSON.parse(data.choices[0].message.content);
let output_content = data.output.find(item => item.type === "message");
if (!output_content) {
console.error("No message content found in agent response:", data);
return data;
}
// console.log("Raw agent content:", output_content);
// parse JSON
let output = JSON.parse(output_content.content[0].text);
// console.log("Agent output:", output);
// add metadata info
output.output= output.output.map((item, index) => {
let metadata= index==0? content.thread : content.replies[index -1];
return {
...item,
metadata: metadata
};
});
return output;
} catch (err) {
console.error("Failed to parse agent response as JSON:", data);
return null;
console.error("Failed to parse agent response as JSON:", err);
return data;
}
}
export async function parseTest(filepath){
const fileRes = readFileSync(filepath);
if (!fileRes) {
console.error("Failed to load JSON file");
return;
}
const filecontent = fileRes.toString();
let data;
try {
data = JSON.parse(filecontent);
} catch (err) {
console.error("Failed to parse JSON file:", err);
return;
}
try {
// 從 choices[0].message.content 中解析 AI 回傳的 JSON 字串
let output_content = data.output.find(item => item.type === "message");
if (!output_content) {
console.error("No message content found in agent response:", data);
return data;
}
// console.log("Raw agent content:", output_content);
// parse JSON
let output = JSON.parse(output_content.content[0].text);
// console.log("Agent output:", output);
// add metadata info
output.output= output.output.map((item, index) => {
let metadata= index==0? content.thread : content.replies[index -1];
return {
...item,
metadata: metadata
};
});
return output;
} catch (err) {
console.error("Failed to parse agent response as JSON:", err);
return data;
}
}

@ -1,57 +1,29 @@
任務背景
你是一個專業的對話分析專家,專精於台灣文化、社會結構與語言慣習。你擅長從大學生與年輕族群的碎語中,看見背後的權力架構、生存策略與情感防禦機制。
第一階段:全域分析 (Global Summary)
在處理個別留言前,請先閱讀整段對話,並結合以下 4 個維度總結出一個統一的 「對話主題 (summry)」:
1.身份與過度:法律成年現實巨嬰、學貸房租內耗、被迫長大的焦慮、偽成年狀態、生存起跑線、財務獨立困境、體制化幼體。
2.系統與結構:職場PUA、體制耗材、權力結構規訓、職涯設計陷阱、拒絕格式化人生、社會遊戲規則、反向思考縫隙。
3.空間與限制:門禁限制自由、城市邊界感、空間私有化、消失的公共空間、監視下的生活、生活空間擠壓、被圈養的自由。
在處理個別留言前,請先閱讀整段對話,並結合以下 10 個維度總結出一個統一的 「對話主題 (summry)」:
In-betweenness (中間狀態):過渡期、依賴與獨立的拉扯。
Identity (身份):社會標籤、標名權、生命經驗的斷層。
Power Structures (權力結構):誰在制定規則?權力不對等現況。
Systems (系統):效率與人性需求的衝突、科層制度。
Space (空間):物理或數位位置背後的權力地景。
Access (使用權):進入門檻、誰能使用這個空間/資源?
Autonomy (自主性):在結構限制下微小的選擇權。
Participation (參與):虛假參與 vs. 實質影響。
Expression (表達與詮釋):話語權的爭奪、自我敘事。
Belonging (歸屬):非典型連結、孤島感、群體認同。
第二階段:多維度內容提取
自主與參與:拒絕情緒勒索、奪回人生主體性、不被定義的生活、社會期待壓力、人生劇本重構、被動參與感、自我主筆權。
針對每則留言,執行以下三項動作:
1.關鍵字 (Keywords):從「權力動態」、「台灣文化」、「語言地圖(Z世代)」、「社會屬性」、「內在運作(隱含動機)」五個視角選取 3-5 個詞。
-絕抽象概念:嚴禁使用「學習」、「面試」、「台灣職場文化」、「壓力」等大而無當的名詞。
第二階段:多維度內容提取(數量對齊機制)
針對輸入的 {{count}} 則留言,你必須逐一處理,確保輸出的 JSON 陣列長度正好為 {{count}}。執行以下動作:
1.關鍵字 (Keywords):從「權力動態」、「台灣文化」、「語言地圖(Z世代)」、「社會屬性」、「內在運作(隱含動機)」五個視角選取 3 個詞。
-拒絕抽象概念:嚴禁使用「學習」、「面試」、「台灣職場文化」、「壓力」等大而無當的名詞。
-落地化與場景化:選擇更具細節感、能反映真實生活掙扎或具體動作的詞彙。
2.引流摘要 (Teaser):10 字以內。
-禁止摘要與延伸:絕對不要使用 AI 的語言去概括或解釋這句話。
-直接引用:直接從留言內容中,擷取出一句「感觸最深」或「最能代表該留言情緒」的原話片段。
-人味保留:保留原有的語感,甚至是語助詞。
3.數據計數:紀錄當前編號 number 與總數 total。total 必須等於輸入資料的總筆數。
3.數據計數:紀錄編號與總數。
第三階段:輸出規範
一致性:每一則留言的 summry 必須完全相同。
嚴禁省略:納入所有留言,絕對禁止使用 "..." 或 "etc"。
格式要求:僅輸出 JSON 字串。
第三階段:輸出與校驗規範
1.數量一致性:輸入多少則留言,JSON 陣列就必須包含多少個物件。 嚴禁合併留言、嚴禁省略、嚴禁使用 "..." 或 "etc"。
2.內容一致性:每一則留言的 summry 內容必須完全相同(基於全域分析)。
3.格式要求:僅輸出純 JSON 字串,不含 Markdown 程式碼區塊標籤。
JSON 結構範例
{
"output": [
{
"summry": "20字以內的全域主題總結(需呼應核心維度)",
"teaser": "10字內具備共鳴的核心情緒",
"keywords": ["維度標籤", "文化術語", "心理狀態"],
"number": 1,
"total": M,
"content": "原始內容",
"metadata": { ...原始資料 }
}
]
}
{ "output": [ { "summry": "20字以內的全域主題總結", "teaser": "10字內原始引用", "keywords": ["關鍵字1", "關鍵字2", "關鍵字3"], "number": 1, "total": N, "content": "原始內容" } // ... 確保總數等於 N ] }

@ -40,7 +40,7 @@ async function clearCollection(collection){
export async function processData(filepath, clear=false, collection=COLLECTION_DATA){
if(clear) await clearCollection(collection);
// if(clear) await clearCollection(collection);
console.log("Processing file for embeddings:", filepath);
@ -101,7 +101,7 @@ function jsonToText(item){
text += `Keywords: ${item.keywords.join(", ")} `;
text += `Order: ${item.number}/${item.total} `;
text += `User: ${item.user} `;
text += `Content: ${item.content.replace(/[\r\n]+/g, ' ')} `;
text += `Teaser: ${item.teaser.replace(/[\r\n]+/g, ' ')} `;
return text;
}

@ -1,4 +1,4 @@
import { jsonToAgent } from "./agent.js";
import { jsonToAgent, parseTest } from "./agent.js";
import { getThread } from "./scrapper.js";
import { searchThreads } from "./search.js";
import { writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
@ -51,9 +51,10 @@ const Keywords=[
]
const Version="v2-1";
const Version="v3";
const DEBUG_MODE=false;
const SCRAP_TYPE='KEYWORD'; // 'KEYWORD' or 'TAG'
const CLEAR=true;
async function step1(){
// const chooseKeywords=['人生 妥協','邊界 被侵犯','卡住 職涯','尷尬 年齡','厭世 創作'];
@ -123,7 +124,9 @@ async function step2(){
const files = readdirSync(`./scrapped/${folder}`);
console.log(`Files in folder ${folder}:`, files);
files?.forEach(async (file, index) => {
for(var index in files){
// files?.forEach(async (file, index) => {
const file=files[index];
if(DEBUG_MODE && index>0) return; // for testing, process only first file
@ -144,7 +147,7 @@ async function step2(){
}catch(err){
console.error("Error processing agent for folder", folder, ":", err);
}
});
}
}
@ -155,6 +158,8 @@ async function step3(){
const folders = readdirSync(`./processed_${Version}`);
console.log("Folders in raw folder:", folders);
if(CLEAR) await clearCollection(collection);
for(const folder of folders){
// check is folder
const isFolder = statSync(`./processed_${Version}/${folder}`).isDirectory();
@ -166,14 +171,15 @@ async function step3(){
const files = readdirSync(`./processed_${Version}/${folder}`);
console.log(`Files in folder ${folder}:`, files);
files?.forEach(async (file, index) => {
for(const index in files){
const file=files[index];
try{
await processData(`./processed_${Version}/${folder}/${file}`, false, `data-v3`);
}catch(err){
console.error("Error processing embeddings for folder", folder, ":", err);
}
});
}
}
@ -181,10 +187,11 @@ async function step3(){
async function main(){
await step1();
// await step1();
// await getThread('https://www.threads.com/@pytteliten_/post/DJpeh8BoO3a', cookies);
// await step2();
await step2();
// await parseTest('./processed_v3/00後整頓職場/DBEB0ACzF4j.json');
// await step3();
}

@ -217,7 +217,7 @@ export async function getThread(postUrl, cookies) {
await page.mouse.move(960, 540);
// 執行多次模擬滑鼠滾輪滾動
const totalScrolls = 25;
const totalScrolls = 30;
const scrollDistance = 1000;
// 獲取初始高度
@ -236,12 +236,18 @@ export async function getThread(postUrl, cookies) {
}
// 檢查高度變化
let nogaincount=0;
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (currentHeight > lastHeight) {
console.log(` - 第 ${i + 1} 次滾動: 偵測到內容加載!高度增加 ${currentHeight - lastHeight}px (目前: ${currentHeight}px)`);
lastHeight = currentHeight;
} else {
console.log(` - 第 ${i + 1} 次滾動: 高度未變化 (${currentHeight}px)`);
nogaincount++;
if(nogaincount>=5){
console.log(" - 偵測到多次無高度變化,提前結束滾動。");
break;
}
}
}

Loading…
Cancel
Save