2469 lines
108 KiB
JavaScript
2469 lines
108 KiB
JavaScript
const fs = require('fs')
|
||
const path = require('path')
|
||
const Database = require('better-sqlite3')
|
||
const axios = require('axios')
|
||
const crypto = require('crypto')
|
||
|
||
const dataDir = path.join(process.cwd(), 'data')
|
||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
||
const dbPath = path.join(dataDir, 'style_check.db')
|
||
const db = new Database(dbPath)
|
||
db.pragma('journal_mode = WAL')
|
||
db.pragma('synchronous = NORMAL')
|
||
|
||
const configDir = path.join(process.cwd(), 'config')
|
||
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true })
|
||
const configPath = path.join(configDir, 'style_check.json')
|
||
if (!fs.existsSync(configPath)) {
|
||
fs.writeFileSync(configPath, JSON.stringify({
|
||
profile: {
|
||
nickname: '上海通勤风',
|
||
city: '上海',
|
||
height_cm: 176,
|
||
weight_kg: 65,
|
||
style_keywords: '简约质感,运动休闲融合,低饱和度',
|
||
avoid_keywords: '西服,大logo,荧光色,夸张印花'
|
||
},
|
||
ai: {
|
||
enabled: true,
|
||
api_key: '',
|
||
api_base: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
||
vision_model: 'qwen-vl-plus',
|
||
timeout_ms: 30000
|
||
}
|
||
}, null, 2))
|
||
}
|
||
const readConfig = () => {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS user_profile (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
nickname TEXT,
|
||
city TEXT,
|
||
height_cm INTEGER,
|
||
weight_kg INTEGER,
|
||
style_keywords TEXT,
|
||
avoid_keywords TEXT,
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS mirror_rules (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
rule_group TEXT NOT NULL,
|
||
rule_text TEXT NOT NULL,
|
||
weight INTEGER NOT NULL DEFAULT 10,
|
||
active_flag INTEGER NOT NULL DEFAULT 1,
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS outfit_templates (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
scene TEXT NOT NULL,
|
||
temp_zone TEXT NOT NULL,
|
||
top_desc TEXT NOT NULL,
|
||
bottom_desc TEXT NOT NULL,
|
||
shoes_desc TEXT NOT NULL,
|
||
outer_desc TEXT DEFAULT '',
|
||
accessory_desc TEXT DEFAULT '',
|
||
notes TEXT DEFAULT '',
|
||
active_flag INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS mirror_checks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
check_date TEXT NOT NULL,
|
||
scene TEXT NOT NULL,
|
||
temp_zone TEXT NOT NULL,
|
||
total_score INTEGER NOT NULL,
|
||
result_level TEXT NOT NULL,
|
||
issues_json TEXT DEFAULT '[]',
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS apparel_items (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
item_name TEXT NOT NULL,
|
||
slot TEXT NOT NULL,
|
||
category TEXT NOT NULL,
|
||
temp_zone TEXT NOT NULL DEFAULT 'ALL',
|
||
season_tag TEXT DEFAULT '',
|
||
brand_options TEXT DEFAULT '',
|
||
price_min INTEGER,
|
||
price_max INTEGER,
|
||
keywords TEXT DEFAULT '',
|
||
notes TEXT DEFAULT '',
|
||
active_flag INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS outfit_item_links (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
outfit_id INTEGER NOT NULL,
|
||
item_id INTEGER NOT NULL,
|
||
slot TEXT NOT NULL,
|
||
rank_no INTEGER NOT NULL DEFAULT 1,
|
||
match_type TEXT NOT NULL DEFAULT 'auto',
|
||
confidence INTEGER NOT NULL DEFAULT 0,
|
||
matched_keyword TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(outfit_id, item_id, slot)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS ai_audits (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
scene TEXT NOT NULL,
|
||
temp_zone TEXT NOT NULL,
|
||
image_hash TEXT DEFAULT '',
|
||
total_score INTEGER NOT NULL DEFAULT 0,
|
||
result_level TEXT NOT NULL DEFAULT 'warn',
|
||
summary TEXT DEFAULT '',
|
||
features_json TEXT DEFAULT '{}',
|
||
rules_json TEXT DEFAULT '{}',
|
||
library_json TEXT DEFAULT '{}',
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS apparel_item_images (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
item_id INTEGER NOT NULL,
|
||
profile_key TEXT NOT NULL DEFAULT 'mature_relaxed',
|
||
image_url TEXT NOT NULL,
|
||
source_type TEXT NOT NULL DEFAULT 'web_seed',
|
||
caption TEXT DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(item_id, image_url)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_style_outfit_scene_temp ON outfit_templates(scene, temp_zone, active_flag);
|
||
CREATE INDEX IF NOT EXISTS idx_style_check_date ON mirror_checks(check_date);
|
||
CREATE INDEX IF NOT EXISTS idx_style_apparel_slot_temp ON apparel_items(slot, temp_zone, active_flag);
|
||
CREATE INDEX IF NOT EXISTS idx_style_links_outfit_slot ON outfit_item_links(outfit_id, slot, rank_no);
|
||
CREATE INDEX IF NOT EXISTS idx_style_ai_created ON ai_audits(created_at);
|
||
CREATE INDEX IF NOT EXISTS idx_style_item_images_item ON apparel_item_images(item_id, profile_key, sort_order);
|
||
CREATE TABLE IF NOT EXISTS wave_references (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
image_url TEXT NOT NULL,
|
||
source_url TEXT,
|
||
evaluation TEXT DEFAULT '',
|
||
notes TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(source_url)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS korean_references (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
image_url TEXT NOT NULL,
|
||
source_url TEXT,
|
||
evaluation TEXT DEFAULT '',
|
||
notes TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(source_url)
|
||
);
|
||
`)
|
||
|
||
const ensureColumn = (tableName, columnName, sql) => {
|
||
const cols = db.prepare(`PRAGMA table_info(${tableName})`).all()
|
||
const has = cols.some(c => String(c.name) === String(columnName))
|
||
if (has) return
|
||
db.exec(sql)
|
||
}
|
||
|
||
ensureColumn('mirror_rules', 'profile_key', `ALTER TABLE mirror_rules ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`)
|
||
ensureColumn('outfit_templates', 'profile_key', `ALTER TABLE outfit_templates ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`)
|
||
ensureColumn('mirror_checks', 'profile_key', `ALTER TABLE mirror_checks ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`)
|
||
ensureColumn('apparel_items', 'profile_key', `ALTER TABLE apparel_items ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`)
|
||
ensureColumn('ai_audits', 'profile_key', `ALTER TABLE ai_audits ADD COLUMN profile_key TEXT NOT NULL DEFAULT 'mature_relaxed'`)
|
||
db.exec(`
|
||
CREATE TABLE IF NOT EXISTS style_profiles (
|
||
profile_key TEXT PRIMARY KEY,
|
||
profile_name TEXT NOT NULL,
|
||
slogan TEXT DEFAULT '',
|
||
theme TEXT NOT NULL DEFAULT 'dark'
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_rules_profile ON mirror_rules(profile_key, active_flag, sort_order);
|
||
CREATE INDEX IF NOT EXISTS idx_outfits_profile ON outfit_templates(profile_key, active_flag, scene, temp_zone);
|
||
CREATE INDEX IF NOT EXISTS idx_items_profile ON apparel_items(profile_key, active_flag, slot, temp_zone);
|
||
CREATE INDEX IF NOT EXISTS idx_checks_profile ON mirror_checks(profile_key, check_date);
|
||
CREATE INDEX IF NOT EXISTS idx_ai_profile ON ai_audits(profile_key, created_at);
|
||
`)
|
||
db.prepare(`UPDATE mirror_rules SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run()
|
||
db.prepare(`UPDATE outfit_templates SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run()
|
||
db.prepare(`UPDATE mirror_checks SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run()
|
||
db.prepare(`UPDATE apparel_items SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run()
|
||
db.prepare(`UPDATE ai_audits SET profile_key='mature_relaxed' WHERE profile_key IS NULL OR profile_key=''`).run()
|
||
db.prepare(`
|
||
INSERT INTO style_profiles (profile_key, profile_name, slogan, theme)
|
||
VALUES
|
||
('mature_relaxed', '中老年休闲沉稳版', '稳重有质感,低饱和、利落、耐看', 'dark'),
|
||
('mature_fashion40', '中老年流行时尚版', '50岁按40岁时尚审美:懂流行、有表达、不另类', 'fashion')
|
||
ON CONFLICT(profile_key) DO UPDATE SET
|
||
profile_name=excluded.profile_name,
|
||
slogan=excluded.slogan,
|
||
theme=excluded.theme
|
||
`).run()
|
||
|
||
const cfg = readConfig()
|
||
const defaultProfile = (cfg && cfg.profile && typeof cfg.profile === 'object') ? cfg.profile : {}
|
||
db.prepare(`
|
||
INSERT INTO user_profile (id, nickname, city, height_cm, weight_kg, style_keywords, avoid_keywords, updated_at)
|
||
VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||
ON CONFLICT(id) DO NOTHING
|
||
`).run(
|
||
String(defaultProfile.nickname || '上海通勤风'),
|
||
String(defaultProfile.city || '上海'),
|
||
Number(defaultProfile.height_cm || 176),
|
||
Number(defaultProfile.weight_kg || 65),
|
||
String(defaultProfile.style_keywords || '简约质感,运动休闲融合,低饱和度'),
|
||
String(defaultProfile.avoid_keywords || '西服,大logo,荧光色,夸张印花')
|
||
)
|
||
|
||
const ruleCount = db.prepare(`SELECT COUNT(1) AS c FROM mirror_rules`).get().c || 0
|
||
if (!ruleCount) {
|
||
const seedRules = [
|
||
['overall', '颜色不超过3种(黑/灰/藏蓝/卡其/米白/军绿)', 15, 10],
|
||
['overall', '全身无大logo、无夸张印花、无荧光色', 12, 20],
|
||
['overall', '肩线平整、衣长不过臀、裤脚不堆褶', 15, 30],
|
||
['top', '上衣合身不紧绷,领口不变形', 10, 40],
|
||
['bottom', '下装不紧身不垮裆,线条利落', 10, 50],
|
||
['shoes', '鞋面干净,鞋型不笨重', 10, 60],
|
||
['vibe', '整体气质清爽干练,有活力不装嫩', 18, 70]
|
||
]
|
||
const stmt = db.prepare(`INSERT INTO mirror_rules (rule_group, rule_text, weight, sort_order) VALUES (?, ?, ?, ?)`)
|
||
const tx = db.transaction((rows) => {
|
||
rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3]))
|
||
})
|
||
tx(seedRules)
|
||
}
|
||
|
||
const outfitCount = db.prepare(`SELECT COUNT(1) AS c FROM outfit_templates`).get().c || 0
|
||
if (!outfitCount) {
|
||
const seedOutfits = [
|
||
['通勤休闲 A', 'commute', '12-22℃', '浅灰亨利领', '深蓝直筒休闲裤', '小白鞋', '轻量短夹克', '简约手表', '上海通勤稳定款'],
|
||
['周末轻运动 A', 'weekend', '22-30℃', '纯色小logo卫衣', '黑灰修身牛仔裤', '复古跑鞋', '', '棒球帽', '健身后可直接穿'],
|
||
['轻商务会面 A', 'business_light', '12-22℃', '浅蓝牛津纺衬衫', '卡其直筒裤', '哑光乐福鞋', '薄针织开衫', '细框眼镜', '成熟但不老气'],
|
||
['冬季通勤 A', 'commute', '5-12℃', '半高领羊绒打底', '深灰加厚休闲裤', '复古慢跑鞋', '藏蓝短款羽绒服', '简约手表', '保暖不臃肿'],
|
||
['盛夏通勤 A', 'commute', '30℃+', '浅蓝亚麻短袖衬衫', '浅卡其休闲短裤', '小白鞋', '轻薄防晒衬衫', '简约皮带', '清爽透气']
|
||
]
|
||
const stmt = db.prepare(`
|
||
INSERT INTO outfit_templates
|
||
(name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||
`)
|
||
const tx = db.transaction((rows) => {
|
||
rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]))
|
||
})
|
||
tx(seedOutfits)
|
||
}
|
||
|
||
const apparelCount = db.prepare(`SELECT COUNT(1) AS c FROM apparel_items`).get().c || 0
|
||
if (!apparelCount) {
|
||
const seedItems = [
|
||
['纯色圆领T恤(春秋)', 'top', 'T恤', '12-22℃', 'spring_autumn', '优衣库、马登、余闲', 79, 199, '圆领,T恤,纯色,白,麻灰,黑', '合身不紧绷,领口不变形'],
|
||
['亨利领T恤(春秋)', 'top', '亨利领', '12-22℃', 'spring_autumn', '单农、海上公路、杰克琼斯', 299, 499, '亨利领,T恤,灰,藏蓝', '比Polo年轻,比T恤精致'],
|
||
['薄款牛津纺衬衫(春秋)', 'top', '衬衫', '12-22℃', 'spring_autumn', '雅戈尔、十如仕、威可多', 399, 680, '牛津纺,衬衫,浅蓝,白,条纹', '免烫、微宽松'],
|
||
['轻量短款夹克(春秋)', 'outer', '夹克', '12-22℃', 'spring_autumn', '七匹狼、单农、之禾', 599, 1299, '夹克,短款,防风,软壳,藏蓝,炭灰', '长度到腰胯'],
|
||
['薄针织V领开衫(春秋)', 'outer', '针织开衫', '12-22℃', 'spring_autumn', '鄂尔多斯、单农、优衣库', 399, 899, '针织,开衫,V领,灰,藏蓝', '贴身不紧身'],
|
||
['简约小logo卫衣(春秋)', 'top', '卫衣', '12-22℃', 'spring_autumn', '杰克琼斯、马登、余闲', 299, 499, '卫衣,小logo,深灰,黑', '利落版型'],
|
||
['深灰/藏蓝直筒休闲裤(春秋)', 'bottom', '休闲裤', '12-22℃', 'spring_autumn', '雅戈尔、七匹狼、海上公路', 299, 599, '直筒,休闲裤,深灰,藏蓝,微弹', '不阔不紧'],
|
||
['深蓝修身牛仔裤(春秋)', 'bottom', '牛仔裤', '12-22℃', 'spring_autumn', '杰克琼斯、李维斯、优衣库', 499, 699, '牛仔裤,深蓝,修身', '基础水洗'],
|
||
['高端束脚卫裤(春秋)', 'bottom', '卫裤', '12-22℃', 'spring_autumn', '单农、余闲、优衣库', 299, 499, '束脚,卫裤,黑,深灰', '通勤+健身两用'],
|
||
['小白鞋/简约板鞋', 'shoes', '板鞋', 'ALL', 'all', 'Common Projects、New Balance、匡威', 599, 1299, '小白鞋,板鞋,简约,白', '通勤日常都能穿'],
|
||
['复古慢跑鞋', 'shoes', '慢跑鞋', 'ALL', 'all', 'New Balance、Asics、Nike', 699, 1299, '慢跑鞋,复古跑鞋,跑鞋,灰,白,黑', '运动休闲融合'],
|
||
['纯色短袖T恤(夏)', 'top', '短袖T恤', '22-30℃', 'summer', '优衣库、马登、余闲', 79, 199, '短袖,T恤,纯色,白,麻灰,藏蓝,浅卡其', '透气不贴身'],
|
||
['亨利领短袖T恤(夏)', 'top', '短袖亨利领', '22-30℃', 'summer', '单农、海上公路、杰克琼斯', 299, 399, '短袖,亨利领,灰,藏蓝', '夏季精致款'],
|
||
['亚麻/天丝短袖衬衫(夏)', 'top', '短袖衬衫', '30℃+', 'summer', '雅戈尔、十如仕、之禾', 499, 799, '亚麻,天丝,短袖衬衫,浅蓝,白,条纹', '透气慵懒'],
|
||
['简约Polo衫(夏)', 'top', 'Polo', '22-30℃', 'summer', '七匹狼、雅戈尔、单农', 399, 599, 'Polo,麻灰,藏蓝,浅卡其', '无大logo'],
|
||
['轻薄防晒衬衫(夏)', 'outer', '防晒衬衫', '30℃+', 'summer', '优衣库、单农、北面', 299, 499, '防晒衬衫,轻薄,浅灰,米白', '可当外套'],
|
||
['直筒休闲短裤(夏)', 'bottom', '短裤', '30℃+', 'summer', '雅戈尔、七匹狼、海上公路', 299, 499, '短裤,直筒,浅卡其,深灰', '膝盖上2cm'],
|
||
['亚麻/天丝休闲长裤(夏)', 'bottom', '休闲长裤', '30℃+', 'summer', '之禾、单农、雅戈尔', 499, 799, '亚麻长裤,天丝长裤,浅灰,米白', '透气垂感'],
|
||
['轻薄束脚卫裤(夏)', 'bottom', '轻薄卫裤', '22-30℃', 'summer', '单农、余闲、优衣库', 299, 399, '轻薄卫裤,束脚,速干', '健身后直接穿'],
|
||
['透气休闲凉鞋/乐福鞋', 'shoes', '凉鞋乐福', '30℃+', 'summer', 'ECCO、Clarks、百丽', 699, 1299, '凉鞋,乐福鞋,无鞋带,透气', '夏季通勤友好'],
|
||
['加厚长袖T恤(冬)', 'top', '长袖T恤', '5-12℃', 'winter', '优衣库、马登、余闲', 129, 249, '长袖T恤,加厚,白,麻灰,黑', '打底基础款'],
|
||
['半高领打底衫(冬)', 'top', '高领打底', '5-12℃', 'winter', '鄂尔多斯、单农、优衣库', 399, 899, '半高领,高领,打底,羊绒,羊毛', '保暖不臃肿'],
|
||
['加厚亨利领T恤(冬)', 'top', '亨利领', '5-12℃', 'winter', '单农、海上公路、杰克琼斯', 399, 599, '加厚,亨利领,T恤,灰,藏蓝', '冬季精致层次'],
|
||
['中厚针织毛衣/V领开衫(冬)', 'outer', '针织毛衣', '5-12℃', 'winter', '鄂尔多斯、单农、之禾', 599, 1299, '毛衣,针织,V领,开衫,灰,藏蓝,黑', '保暖有型'],
|
||
['轻量短款羽绒服/棉服(冬)', 'outer', '羽绒服', '5-12℃', 'winter', '单农、北面、优衣库', 899, 1999, '羽绒服,棉服,短款,藏蓝,炭灰,黑', '修身不臃肿'],
|
||
['中长款羊毛风衣(冬)', 'outer', '风衣', '5-12℃', 'winter', '鄂尔多斯、之禾、威可多', 1600, 3200, '风衣,羊毛,中长款,炭灰,藏蓝', '挺括抗风'],
|
||
['加厚直筒休闲裤(冬)', 'bottom', '加厚休闲裤', '5-12℃', 'winter', '雅戈尔、七匹狼、海上公路', 399, 699, '加厚,直筒,休闲裤,深灰,藏蓝,黑', '加薄绒'],
|
||
['加厚修身牛仔裤(冬)', 'bottom', '加厚牛仔裤', '5-12℃', 'winter', '杰克琼斯、李维斯、优衣库', 599, 799, '加厚,牛仔裤,深蓝,黑灰', '基础款'],
|
||
['加厚束脚卫裤(冬)', 'bottom', '加厚卫裤', '5-12℃', 'winter', '单农、余闲、优衣库', 399, 599, '加厚,束脚,卫裤,黑,深灰', '健身+日常'],
|
||
['哑光休闲皮鞋/乐福鞋(冬)', 'shoes', '皮鞋乐福', '5-12℃', 'winter', 'ECCO、Clarks、百丽', 899, 1599, '皮鞋,乐福鞋,哑光,加薄绒', '成熟不老气'],
|
||
['简约手表', 'accessory', '手表', 'ALL', 'all', '天梭、美度、海鸥', 2000, 5000, '手表,金属表带,皮质表带', '少而精'],
|
||
['细框眼镜', 'accessory', '眼镜', 'ALL', 'all', '木九十、暴龙、精工', 500, 1500, '眼镜,细框', '斯文减龄'],
|
||
['纯色棒球帽', 'accessory', '帽子', 'ALL', 'all', 'New Era、优衣库、单农', 99, 299, '棒球帽,灰,黑,藏蓝', '运动休闲融合'],
|
||
['简约皮带', 'accessory', '皮带', 'ALL', 'all', '金利来、七匹狼、ECCO', 299, 599, '皮带,黑,深棕', '无大logo']
|
||
]
|
||
const stmt = db.prepare(`
|
||
INSERT INTO apparel_items
|
||
(item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||
`)
|
||
const tx = db.transaction((rows) => {
|
||
rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9]))
|
||
})
|
||
tx(seedItems)
|
||
}
|
||
|
||
const fashionRuleCount = db.prepare(`SELECT COUNT(1) AS c FROM mirror_rules WHERE profile_key='mature_fashion40'`).get().c || 0
|
||
if (!fashionRuleCount) {
|
||
const seedRules = [
|
||
['overall', '颜色控制在4种以内,并至少有1处时尚点缀', 12, 10],
|
||
['overall', '允许小面积图案,但禁止大logo、荧光色、夸张印花', 14, 20],
|
||
['overall', '主轮廓要明确:上窄下松或上松下窄', 12, 30],
|
||
['top', '上衣有设计感但不过度:领型/门襟/面料至少一项有亮点', 10, 40],
|
||
['bottom', '下装线条干净,避免紧绷和拖沓', 10, 50],
|
||
['shoes', '鞋保持干净,允许复古或轻设计但不笨重', 10, 60],
|
||
['vibe', '整体像懂时尚的40岁审美:有表达、不另类', 18, 70]
|
||
]
|
||
const stmt = db.prepare(`INSERT INTO mirror_rules (rule_group, rule_text, weight, sort_order, profile_key) VALUES (?, ?, ?, ?, 'mature_fashion40')`)
|
||
const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3])))
|
||
tx(seedRules)
|
||
}
|
||
|
||
const fashionOutfitCount = db.prepare(`SELECT COUNT(1) AS c FROM outfit_templates WHERE profile_key='mature_fashion40'`).get().c || 0
|
||
if (!fashionOutfitCount) {
|
||
const seedOutfits = [
|
||
['时尚通勤 A', 'commute', '12-22℃', '深灰半高领打底', '锥形九分西装感休闲裤', '干净复古跑鞋', '短款立领夹克', '金属表带手表', '成熟通勤但有时尚表达'],
|
||
['城市周末 A', 'weekend', '22-30℃', '微廓形落肩T恤', '垂感直筒裤', '低帮板鞋', '轻机能衬衫外搭', '细框墨镜', '有层次但不过度'],
|
||
['社交会面 A', 'business_light', '12-22℃', '暗纹亨利领针织', '炭灰锥形裤', '哑光乐福鞋', '短风衣', '简约皮带', '像看过杂志的成熟气质'],
|
||
['冬季时尚 A', 'commute', '5-12℃', '黑色羊毛半高领', '深灰加厚直筒裤', '复古运动鞋', '炭灰短款羽绒服', '针织围巾', '保暖+时髦平衡'],
|
||
['盛夏时尚 A', 'commute', '30℃+', '米白天丝短袖衬衫', '浅灰轻薄锥形裤', '透气乐福鞋', '薄防晒衬衫', '金属框眼镜', '轻熟时尚不油腻']
|
||
]
|
||
const stmt = db.prepare(`
|
||
INSERT INTO outfit_templates
|
||
(name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, profile_key, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now'))
|
||
`)
|
||
const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8])))
|
||
tx(seedOutfits)
|
||
}
|
||
|
||
const fashionItemCount = db.prepare(`SELECT COUNT(1) AS c FROM apparel_items WHERE profile_key='mature_fashion40'`).get().c || 0
|
||
if (!fashionItemCount) {
|
||
const seedItems = [
|
||
['微廓形落肩T恤', 'top', '时尚T恤', '22-30℃', 'summer', '优衣库、UR、GXG', 129, 299, '落肩,T恤,微廓形,纯色', '保留宽松感但利落'],
|
||
['暗纹亨利领针织', 'top', '针织上衣', '12-22℃', 'spring_autumn', '海澜之家、太平鸟男装、杰克琼斯', 299, 599, '暗纹,亨利领,针织', '成熟且时尚'],
|
||
['短款立领夹克', 'outer', '夹克', '12-22℃', 'spring_autumn', 'GXG、太平鸟男装、七匹狼', 499, 999, '短款,立领,夹克,结构感', '提升轮廓'],
|
||
['轻机能衬衫外搭', 'outer', '机能衬衫', '22-30℃', 'summer', '优衣库、UR、海澜之家', 199, 499, '机能,衬衫,外搭,轻薄', '层次和功能感'],
|
||
['短风衣', 'outer', '风衣', '12-22℃', 'spring_autumn', '雅戈尔、报喜鸟、太平鸟男装', 699, 1499, '风衣,短款,挺括', '不拖沓更年轻'],
|
||
['锥形九分休闲裤', 'bottom', '锥形裤', '12-22℃', 'all', '雅戈尔、海澜之家、优衣库', 299, 699, '锥形,九分,休闲裤', '显腿型'],
|
||
['垂感直筒裤', 'bottom', '垂感裤', '22-30℃', 'summer', 'UR、太平鸟男装、优衣库', 199, 599, '垂感,直筒,轻薄', '线条干净'],
|
||
['浅灰轻薄锥形裤', 'bottom', '夏季锥形裤', '30℃+', 'summer', '优衣库、海澜之家、GXG', 199, 499, '浅灰,轻薄,锥形', '夏季不闷'],
|
||
['干净复古跑鞋', 'shoes', '复古跑鞋', 'ALL', 'all', 'New Balance、Asics、Nike', 499, 999, '复古跑鞋,低饱和,干净', '时尚感核心单品'],
|
||
['透气乐福鞋', 'shoes', '乐福鞋', '30℃+', 'summer', 'ECCO、Clarks、百丽', 599, 1299, '乐福鞋,透气,低帮', '轻熟社交'],
|
||
['金属表带手表', 'accessory', '手表', 'ALL', 'all', '天梭、海鸥、卡西欧', 800, 3500, '手表,金属表带,简约', '强化成熟时尚感'],
|
||
['细框墨镜', 'accessory', '眼镜', 'ALL', 'all', '暴龙、木九十、陌森', 299, 999, '墨镜,细框,金属', '点睛不夸张']
|
||
]
|
||
const stmt = db.prepare(`
|
||
INSERT INTO apparel_items
|
||
(item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now'))
|
||
`)
|
||
const tx = db.transaction(rows => rows.forEach(r => stmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9])))
|
||
tx(seedItems)
|
||
}
|
||
|
||
let needRebuildFashionLinks = false
|
||
const fashionMissingRows = [
|
||
['深灰半高领打底', 'top', '打底上衣', '5-12℃', 'winter', '优衣库、凡客诚品、HLA海澜之家', 79, 199, '半高领,打底,深灰,纯棉,美利奴', '保暖不臃肿,单穿或内搭都显利落'],
|
||
['低帮板鞋', 'shoes', '板鞋', 'ALL', 'all', '回力、飞跃、安踏', 89, 299, '低帮,板鞋,纯色,复古', '经典白黑藏蓝,百搭通勤'],
|
||
['哑光乐福鞋', 'shoes', '乐福鞋', 'ALL', 'all', '森达、奥康、红蜻蜓', 299, 599, '哑光,乐福鞋,牛皮,简约', '轻熟商务感,不浮夸'],
|
||
['黑色羊毛半高领', 'top', '高领打底', '5-12℃', 'winter', '优衣库、凡客、七匹狼', 129, 299, '羊毛,半高领,黑色,抗起球', '冬季内搭核心,更显年轻'],
|
||
['深灰加厚直筒裤', 'bottom', '加厚休闲裤', '5-12℃', 'winter', '优衣库EZY裤、海澜之家、凡客', 159, 299, '加厚,直筒,垂感,抗皱', '冬季保暖不臃肿'],
|
||
['复古运动鞋', 'shoes', '复古跑鞋', 'ALL', 'all', '安踏、李宁、New Balance 574', 299, 599, '复古,低饱和,缓震,百搭', '舒适与时尚兼顾,避免笨重'],
|
||
['炭灰短款羽绒服', 'outer', '羽绒服', '5-12℃', 'winter', '波司登、鸭鸭、优衣库', 499, 899, '炭灰,短款,立领,90绒,哑光', '提升腰线显高,避免拖沓'],
|
||
['针织围巾', 'accessory', '围巾', 'ALL', 'winter', '优衣库、网易严选、无印良品', 59, 199, '围巾,羊毛,羊绒,纯色,窄版', '保暖点缀,减轻厚重感'],
|
||
['米白天丝短袖衬衫', 'top', '短袖衬衫', '30℃+', 'summer', '凡客诚品、优衣库、海澜之家', 99, 249, '米白,天丝,短袖衬衫,抗皱', '夏季通勤垂顺不闷汗'],
|
||
['薄防晒衬衫外搭', 'outer', '防晒衬衫', '30℃+', 'summer', '优衣库AIRism、蕉下、凡客', 129, 249, '防晒,UPF50+,轻薄,透气,外搭', '增加层次,避免单调']
|
||
]
|
||
{
|
||
const hasStmt = db.prepare(`SELECT id FROM apparel_items WHERE profile_key='mature_fashion40' AND item_name=? LIMIT 1`)
|
||
const insertStmt = db.prepare(`
|
||
INSERT INTO apparel_items
|
||
(item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'mature_fashion40', datetime('now'))
|
||
`)
|
||
const tx = db.transaction((rows) => {
|
||
let inserted = 0
|
||
rows.forEach(r => {
|
||
const exists = hasStmt.get(r[0])
|
||
if (exists) return
|
||
insertStmt.run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9])
|
||
inserted += 1
|
||
})
|
||
return inserted
|
||
})
|
||
const insertedCount = tx(fashionMissingRows)
|
||
needRebuildFashionLinks = insertedCount > 0
|
||
}
|
||
|
||
const PROFILE_KEYS = new Set(['mature_relaxed', 'mature_fashion40'])
|
||
const normProfileKey = v => {
|
||
const s = String(v || '').trim()
|
||
if (PROFILE_KEYS.has(s)) return s
|
||
return 'mature_relaxed'
|
||
}
|
||
|
||
const SCENES = new Set(['commute', 'weekend', 'business_light'])
|
||
const TEMP_ZONES = new Set(['5-12℃', '12-22℃', '22-30℃', '30℃+'])
|
||
|
||
const normScene = v => {
|
||
const s = String(v || '').trim()
|
||
if (SCENES.has(s)) return s
|
||
return 'commute'
|
||
}
|
||
|
||
const zoneByTemp = tempC => {
|
||
const n = Number(tempC)
|
||
if (!Number.isFinite(n)) return ''
|
||
if (n < 12) return '5-12℃'
|
||
if (n < 22) return '12-22℃'
|
||
if (n < 30) return '22-30℃'
|
||
return '30℃+'
|
||
}
|
||
|
||
const normZone = (zone, tempC) => {
|
||
const z = String(zone || '').trim()
|
||
if (TEMP_ZONES.has(z)) return z
|
||
return zoneByTemp(tempC) || '12-22℃'
|
||
}
|
||
|
||
const splitKeywords = text => String(text || '').split(/[,,、/+\s]+/).map(v => v.trim()).filter(Boolean)
|
||
const normText = text => String(text || '').toLowerCase().replace(/\s+/g, '')
|
||
|
||
const localImageUrlPrefix = '/images/style_check_images'
|
||
const localImageRoot = path.join(process.cwd(), 'public', 'images', 'style_check_images')
|
||
if (!fs.existsSync(localImageRoot)) fs.mkdirSync(localImageRoot, { recursive: true })
|
||
|
||
const isLocalImageUrl = (url) => String(url || '').startsWith(`${localImageUrlPrefix}/`)
|
||
const guessExtByContentType = (ct) => {
|
||
const c = String(ct || '').toLowerCase()
|
||
if (c.includes('image/jpeg') || c.includes('image/jpg')) return '.jpg'
|
||
if (c.includes('image/png')) return '.png'
|
||
if (c.includes('image/webp')) return '.webp'
|
||
if (c.includes('image/gif')) return '.gif'
|
||
return '.jpg'
|
||
}
|
||
|
||
const saveImageBufferToLocal = (profileKey, itemId, buffer, ext, orderHint = 1) => {
|
||
const dir = path.join(localImageRoot, profileKey, String(itemId))
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||
const ts = Date.now()
|
||
const rnd = Math.floor(Math.random() * 100000)
|
||
const fileName = `${ts}_${orderHint}_${rnd}${ext}`
|
||
const abs = path.join(dir, fileName)
|
||
fs.writeFileSync(abs, buffer)
|
||
return `${localImageUrlPrefix}/${profileKey}/${itemId}/${fileName}`
|
||
}
|
||
|
||
const downloadImageToLocal = async (url, profileKey, itemId, orderHint = 1) => {
|
||
try {
|
||
const res = await axios.get(url, {
|
||
timeout: 10000,
|
||
responseType: 'arraybuffer',
|
||
maxRedirects: 10,
|
||
validateStatus: s => s >= 200 && s < 400,
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||
Accept: 'image/*,*/*;q=0.8'
|
||
}
|
||
})
|
||
const ct = String((res.headers && (res.headers['content-type'] || res.headers['Content-Type'])) || '')
|
||
if (!ct.toLowerCase().includes('image/')) throw new Error('not_image_content_type: ' + ct)
|
||
const buf = Buffer.from(res.data)
|
||
if (!buf || buf.length < 2000) throw new Error('image_too_small: ' + buf.length)
|
||
const ext = guessExtByContentType(ct)
|
||
return saveImageBufferToLocal(profileKey, itemId, buf, ext, orderHint)
|
||
} catch (err) {
|
||
console.error(`downloadImageToLocal error for ${url}:`, err.message)
|
||
throw err
|
||
}
|
||
}
|
||
|
||
const listActiveLibraryItems = (profileKey = 'mature_relaxed') => db.prepare(`
|
||
SELECT id, item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes
|
||
FROM apparel_items
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY slot ASC, id ASC
|
||
`).all(profileKey)
|
||
|
||
const listItemImages = (itemId, profileKey) => db.prepare(`
|
||
SELECT id, item_id, profile_key, image_url, source_type, caption, sort_order, created_at
|
||
FROM apparel_item_images
|
||
WHERE item_id=? AND profile_key=?
|
||
ORDER BY sort_order ASC, id ASC
|
||
`).all(itemId, profileKey)
|
||
|
||
const guessSlotQueryWord = slot => {
|
||
const s = String(slot || '')
|
||
if (s === 'top') return 'top'
|
||
if (s === 'bottom') return 'pants'
|
||
if (s === 'shoes') return 'shoes'
|
||
if (s === 'outer') return 'jacket'
|
||
if (s === 'accessory') return 'accessory'
|
||
return 'fashion'
|
||
}
|
||
|
||
const buildWebSeedUrls = async (item) => {
|
||
const base = `${item.item_name} ${item.category || ''} ${item.keywords || ''}`.trim()
|
||
const q = `${base} 男装`
|
||
try {
|
||
const res = await axios.get(`https://image.so.com/j?q=${encodeURIComponent(q)}&sn=0&pn=10`, {
|
||
timeout: 5000,
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36'
|
||
}
|
||
})
|
||
const list = res.data && res.data.list
|
||
if (Array.isArray(list)) {
|
||
return list.map(x => x.img).filter(x => x && x.startsWith('http'))
|
||
}
|
||
} catch (e) {
|
||
console.error('[StyleCheck] buildWebSeedUrls error:', e.message)
|
||
}
|
||
return []
|
||
}
|
||
|
||
const getImageMissingReport = (profileKey = 'mature_relaxed') => {
|
||
const items = db.prepare(`
|
||
SELECT i.id, i.item_name, i.slot, COUNT(img.id) AS image_count
|
||
FROM apparel_items i
|
||
LEFT JOIN apparel_item_images img
|
||
ON img.item_id=i.id AND img.profile_key=i.profile_key AND img.image_url LIKE '${localImageUrlPrefix}/%'
|
||
WHERE i.active_flag=1 AND i.profile_key=?
|
||
GROUP BY i.id, i.item_name, i.slot
|
||
HAVING image_count < 3
|
||
ORDER BY i.slot ASC, i.id ASC
|
||
`).all(profileKey)
|
||
return items
|
||
}
|
||
|
||
const parseImageDataUrl = (dataUrl) => {
|
||
const s = String(dataUrl || '')
|
||
const m = s.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]+)$/)
|
||
if (!m) return null
|
||
const mime = m[1].toLowerCase()
|
||
const b64 = m[2]
|
||
const extMap = {
|
||
'image/jpeg': '.jpg',
|
||
'image/jpg': '.jpg',
|
||
'image/png': '.png',
|
||
'image/webp': '.webp',
|
||
'image/gif': '.gif'
|
||
}
|
||
const ext = extMap[mime]
|
||
if (!ext) return null
|
||
try {
|
||
const buf = Buffer.from(b64, 'base64')
|
||
if (!buf || !buf.length) return null
|
||
return { mime, ext, buffer: buf }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const saveUploadedLibraryImage = (profileKey, itemId, dataUrl) => {
|
||
const parsed = parseImageDataUrl(dataUrl)
|
||
if (!parsed) throw new Error('invalid_image_data_url')
|
||
return saveImageBufferToLocal(profileKey, itemId, parsed.buffer, parsed.ext, 999)
|
||
}
|
||
|
||
let backgroundFetchTimer = null
|
||
let isBackgroundFetchEnabled = false
|
||
|
||
// Logging system for background fetch
|
||
const bgFetchState = {
|
||
status: 'stopped', // 'stopped', 'running', 'completed'
|
||
startTime: null,
|
||
endTime: null,
|
||
totalItemsToProcess: 0,
|
||
itemsProcessed: 0,
|
||
imagesDownloaded: 0,
|
||
errors: [],
|
||
lastRunTime: null
|
||
}
|
||
|
||
const getBgFetchReport = () => {
|
||
return {
|
||
...bgFetchState,
|
||
uptime_minutes: bgFetchState.startTime ? Math.round((Date.now() - bgFetchState.startTime) / 60000) : 0,
|
||
progress: bgFetchState.totalItemsToProcess > 0
|
||
? Math.round((bgFetchState.itemsProcessed / bgFetchState.totalItemsToProcess) * 100) + '%'
|
||
: '0%'
|
||
}
|
||
}
|
||
|
||
const stopBackgroundFetch = () => {
|
||
if (backgroundFetchTimer) {
|
||
clearInterval(backgroundFetchTimer)
|
||
backgroundFetchTimer = null
|
||
}
|
||
isBackgroundFetchEnabled = false
|
||
bgFetchState.status = bgFetchState.status === 'running' ? 'stopped' : bgFetchState.status
|
||
bgFetchState.endTime = Date.now()
|
||
console.log('[StyleCheck] Background fetch stopped. Report:', getBgFetchReport())
|
||
}
|
||
|
||
const startBackgroundFetch = () => {
|
||
if (backgroundFetchTimer) return
|
||
isBackgroundFetchEnabled = true
|
||
bgFetchState.status = 'running'
|
||
bgFetchState.startTime = Date.now()
|
||
bgFetchState.endTime = null
|
||
bgFetchState.itemsProcessed = 0
|
||
bgFetchState.imagesDownloaded = 0
|
||
bgFetchState.errors = []
|
||
bgFetchState.isProcessing = false
|
||
|
||
console.log('[StyleCheck] Background fetch started. Will run every 5 minutes.')
|
||
|
||
const runFetch = async () => {
|
||
if (!isBackgroundFetchEnabled) return
|
||
if (bgFetchState.isProcessing) {
|
||
console.log('[StyleCheck] Previous fetch is still running, skipping this tick.')
|
||
return
|
||
}
|
||
bgFetchState.isProcessing = true
|
||
bgFetchState.lastRunTime = Date.now()
|
||
try {
|
||
console.log('[StyleCheck] Running scheduled background fetch...')
|
||
|
||
// We will check both profiles
|
||
const profiles = ['mature_relaxed', 'mature_fashion40']
|
||
|
||
// Calculate total items missing images on first run
|
||
if (bgFetchState.totalItemsToProcess === 0) {
|
||
let missing = 0
|
||
for (const pk of profiles) {
|
||
const items = db.prepare(`SELECT id FROM apparel_items WHERE active_flag=1 AND profile_key=?`).all(pk)
|
||
for (const item of items) {
|
||
const count = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE item_id=? AND profile_key=? AND image_url LIKE '/images/%'`).get(item.id, pk).c
|
||
if (count < 3) missing++
|
||
}
|
||
}
|
||
bgFetchState.totalItemsToProcess = missing
|
||
}
|
||
|
||
let totalNewImages = 0
|
||
let totalMissingRemaining = 0
|
||
let processedSomething = false
|
||
|
||
for (const pk of profiles) {
|
||
const countBefore = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE profile_key=? AND image_url LIKE '/images/%'`).get(pk).c
|
||
const result = await ensureItemLocalImages(pk, 3, false, 1) // only process 1 item per run to avoid rate limits
|
||
const countAfter = db.prepare(`SELECT count(*) as c FROM apparel_item_images WHERE profile_key=? AND image_url LIKE '/images/%'`).get(pk).c
|
||
|
||
const newImages = countAfter - countBefore
|
||
totalNewImages += newImages
|
||
totalMissingRemaining += result.missing_count
|
||
|
||
if (newImages > 0 || (result.missing_count > 0 && result.item_count > 0)) {
|
||
processedSomething = true
|
||
}
|
||
}
|
||
|
||
if (totalNewImages > 0) {
|
||
bgFetchState.imagesDownloaded += totalNewImages
|
||
bgFetchState.itemsProcessed += 1
|
||
} else if (processedSomething && bgFetchState.itemsProcessed < bgFetchState.totalItemsToProcess) {
|
||
bgFetchState.itemsProcessed += 1
|
||
}
|
||
|
||
if (totalMissingRemaining === 0) {
|
||
console.log('[StyleCheck] All items in all profiles have enough images. Stopping background fetch.')
|
||
bgFetchState.status = 'completed'
|
||
bgFetchState.itemsProcessed = bgFetchState.totalItemsToProcess // ensure progress hits 100%
|
||
stopBackgroundFetch()
|
||
} else {
|
||
console.log(`[StyleCheck] Processed. Still ${totalMissingRemaining} items missing images total. Total downloaded so far: ${bgFetchState.imagesDownloaded}`)
|
||
}
|
||
} catch (e) {
|
||
console.error('[StyleCheck] Background fetch error:', e.message)
|
||
bgFetchState.errors.push(`[${new Date().toISOString()}] ${e.message}`)
|
||
// Keep only last 10 errors
|
||
if (bgFetchState.errors.length > 10) bgFetchState.errors.shift()
|
||
} finally {
|
||
bgFetchState.isProcessing = false
|
||
}
|
||
}
|
||
|
||
// Run first time after 5s, then every 5 mins
|
||
setTimeout(runFetch, 5000)
|
||
backgroundFetchTimer = setInterval(runFetch, 5 * 60 * 1000)
|
||
}
|
||
|
||
const delay = ms => new Promise(r => setTimeout(r, ms))
|
||
|
||
const callVisionJudgeItemImage = async (localUrl, item) => {
|
||
try {
|
||
const ai = getAiConfig()
|
||
if (!ai.enabled || !ai.apiKey) {
|
||
console.log(`[AI Judge] Disabled or missing API key, skipping judge for ${item.item_name}`)
|
||
return true // Fallback to accept if AI is disabled
|
||
}
|
||
|
||
const abs = path.join(process.cwd(), 'public', localUrl.replace(/^\//, '').replace(/\//g, path.sep))
|
||
if (!fs.existsSync(abs)) return false
|
||
const buf = fs.readFileSync(abs)
|
||
const ext = path.extname(abs).toLowerCase()
|
||
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`
|
||
|
||
const systemPrompt = [
|
||
'你是资深的男装时尚买手与图像审核专家。',
|
||
`当前需要判断该图片是否适合作为【${item.item_name}】(类别:${item.category})的商品展示图。`,
|
||
`商品关键词参考:${item.keywords || '无'}。`,
|
||
'审核要求:',
|
||
'1. 必须是【男性服装】或【男性模特】穿着展示,或者清晰的单品图。如果出现明显女性模特、童装、无关风景、表情包、二维码、纯文字截图等,请直接判定为不合格。',
|
||
'2. 图片质量要求清晰,服装主体完整可见。',
|
||
'3. 符合商品本身的描述与类别。',
|
||
'必须严格输出JSON格式,不要包含任何解释或Markdown符号:',
|
||
'{"approved": true或false, "reason": "简短判定理由"}'
|
||
].join('\n')
|
||
|
||
const payload = {
|
||
model: ai.visionModel,
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: '请审核图片是否合格,直接输出JSON' },
|
||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||
]
|
||
}
|
||
],
|
||
temperature: 0.1
|
||
}
|
||
|
||
const response = await axios.post(ai.apiBase, payload, {
|
||
timeout: ai.timeoutMs * 2,
|
||
headers: {
|
||
Authorization: `Bearer ${ai.apiKey}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
const content = response.data && response.data.choices && response.data.choices[0] && response.data.choices[0].message ? response.data.choices[0].message.content : ''
|
||
const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content)
|
||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||
|
||
if (jsonMatch) {
|
||
const j = JSON.parse(jsonMatch[0])
|
||
console.log(`[AI Judge] ${item.item_name} -> ${j.approved ? '✅ PASS' : '❌ REJECT'} : ${j.reason}`)
|
||
return !!j.approved
|
||
} else {
|
||
console.log(`[AI Judge] Failed to parse JSON for ${item.item_name}, fallback to true`)
|
||
return true
|
||
}
|
||
} catch (e) {
|
||
console.error(`[AI Judge] Error for ${item.item_name}:`, e.message)
|
||
return true // fail safe to not block downloading completely
|
||
}
|
||
}
|
||
|
||
const ensureItemLocalImages = async (profileKey = 'mature_relaxed', targetPerItem = 5, force = false, limitItems = 0) => {
|
||
const items = db.prepare(`
|
||
SELECT id, item_name, slot, category, keywords
|
||
FROM apparel_items
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY id ASC
|
||
`).all(profileKey)
|
||
|
||
// If limit is set, we prioritize items that don't have enough local images
|
||
let targetItems = items
|
||
if (limitItems > 0 && !force) {
|
||
const missingItems = []
|
||
for (const item of items) {
|
||
const current = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url))
|
||
if (current.length < targetPerItem) {
|
||
missingItems.push(item)
|
||
}
|
||
}
|
||
targetItems = missingItems
|
||
}
|
||
|
||
const ins = db.prepare(`
|
||
INSERT OR IGNORE INTO apparel_item_images
|
||
(item_id, profile_key, image_url, source_type, caption, sort_order)
|
||
VALUES (?, ?, ?, ?, '', ?)
|
||
`)
|
||
const delRemote = db.prepare(`
|
||
DELETE FROM apparel_item_images
|
||
WHERE item_id=? AND profile_key=? AND image_url NOT LIKE '${localImageUrlPrefix}/%'
|
||
`)
|
||
const report = []
|
||
let processedItems = 0
|
||
for (const item of targetItems) {
|
||
if (limitItems > 0 && processedItems >= limitItems) break
|
||
if (force) {
|
||
const old = listItemImages(item.id, profileKey)
|
||
old.forEach(one => {
|
||
const u = String(one.image_url || '')
|
||
if (u.startsWith(`${localImageUrlPrefix}/`)) {
|
||
const abs = path.join(process.cwd(), 'public', u.replace(/^\//, '').replace(/\//g, path.sep))
|
||
if (fs.existsSync(abs)) {
|
||
try { fs.unlinkSync(abs) } catch {}
|
||
}
|
||
}
|
||
})
|
||
db.prepare(`DELETE FROM apparel_item_images WHERE item_id=? AND profile_key=?`).run(item.id, profileKey)
|
||
}
|
||
const current = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url))
|
||
let localCount = current.length
|
||
let order = localCount + 1
|
||
const existedUrls = new Set(current.map(x => String(x.image_url || '')))
|
||
const oldRemote = listItemImages(item.id, profileKey).filter(r => !isLocalImageUrl(r.image_url))
|
||
for (const one of oldRemote) {
|
||
if (localCount >= targetPerItem) break
|
||
const u = String(one.image_url || '')
|
||
if (!u || !/^https?:\/\//i.test(u)) continue
|
||
try {
|
||
const localUrl = await downloadImageToLocal(u, profileKey, item.id, order)
|
||
if (!existedUrls.has(localUrl)) {
|
||
ins.run(item.id, profileKey, localUrl, 'web_localized', order)
|
||
existedUrls.add(localUrl)
|
||
localCount += 1
|
||
order += 1
|
||
}
|
||
} catch {}
|
||
}
|
||
if (localCount < targetPerItem) {
|
||
const candidates = await buildWebSeedUrls(item)
|
||
for (const u of candidates) {
|
||
if (localCount >= targetPerItem) break
|
||
try {
|
||
const localUrl = await downloadImageToLocal(u, profileKey, item.id, order)
|
||
if (!existedUrls.has(localUrl)) {
|
||
let isApproved = true
|
||
if (profileKey === 'mature_fashion40') {
|
||
console.log(`[AI Judge] Checking new image for ${item.item_name}...`)
|
||
await delay(30000) // Sleep 30 seconds before calling AI to be slow and gentle
|
||
isApproved = await callVisionJudgeItemImage(localUrl, item)
|
||
}
|
||
|
||
if (isApproved) {
|
||
ins.run(item.id, profileKey, localUrl, 'web_download', order)
|
||
existedUrls.add(localUrl)
|
||
localCount += 1
|
||
order += 1
|
||
} else {
|
||
// Delete rejected image
|
||
const abs = path.join(process.cwd(), 'public', localUrl.replace(/^\//, '').replace(/\//g, path.sep))
|
||
if (fs.existsSync(abs)) {
|
||
try { fs.unlinkSync(abs) } catch {}
|
||
}
|
||
}
|
||
|
||
if (profileKey === 'mature_fashion40') {
|
||
await delay(30000) // Sleep another 30 seconds after processing to space out
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
delRemote.run(item.id, profileKey)
|
||
if (localCount < 3) {
|
||
report.push({ id: item.id, item_name: item.item_name, slot: item.slot, image_count: localCount })
|
||
} else {
|
||
processedItems += 1
|
||
}
|
||
}
|
||
// Compute accurate missing count at the end
|
||
const finalMissing = []
|
||
for (const item of items) {
|
||
const localCount = listItemImages(item.id, profileKey).filter(r => isLocalImageUrl(r.image_url)).length
|
||
if (localCount < targetPerItem) {
|
||
finalMissing.push({ id: item.id, item_name: item.item_name, slot: item.slot, image_count: localCount })
|
||
}
|
||
}
|
||
|
||
return { item_count: items.length, missing: finalMissing, missing_count: finalMissing.length }
|
||
}
|
||
|
||
const buildCandidatesForSlot = (slot, descText, tempZone, items) => {
|
||
const desc = String(descText || '').trim()
|
||
if (!desc) return []
|
||
const nDesc = normText(desc)
|
||
const candidates = items
|
||
.filter(it => it.slot === slot)
|
||
.map(it => {
|
||
const kws = splitKeywords(it.keywords)
|
||
let hit = ''
|
||
let score = 0
|
||
if (nDesc.includes(normText(it.item_name))) {
|
||
score += 55
|
||
hit = it.item_name
|
||
}
|
||
for (const kw of kws) {
|
||
if (!kw) continue
|
||
if (nDesc.includes(normText(kw))) {
|
||
score += 24
|
||
if (!hit) hit = kw
|
||
}
|
||
}
|
||
if (String(it.temp_zone || 'ALL') === String(tempZone || '')) score += 20
|
||
if (String(it.temp_zone || 'ALL') === 'ALL') score += 10
|
||
return { item: it, score, hit }
|
||
})
|
||
.filter(x => x.score >= 30)
|
||
.sort((a, b) => b.score - a.score || Number(a.item.id) - Number(b.item.id))
|
||
return candidates.slice(0, 3)
|
||
}
|
||
|
||
const evaluateOutfit = (outfit, items) => {
|
||
const slotInputs = [
|
||
['top', outfit.top_desc],
|
||
['bottom', outfit.bottom_desc],
|
||
['shoes', outfit.shoes_desc],
|
||
['outer', outfit.outer_desc],
|
||
['accessory', outfit.accessory_desc]
|
||
]
|
||
const links = []
|
||
const unmatched = []
|
||
slotInputs.forEach(([slot, text]) => {
|
||
const t = String(text || '').trim()
|
||
if (!t) return
|
||
const candidates = buildCandidatesForSlot(slot, t, outfit.temp_zone, items)
|
||
if (!candidates.length) {
|
||
unmatched.push({ outfit_id: outfit.id, outfit_name: outfit.name, slot, text: t })
|
||
return
|
||
}
|
||
candidates.forEach((c, idx) => {
|
||
links.push({
|
||
outfit_id: outfit.id,
|
||
item_id: c.item.id,
|
||
slot,
|
||
rank_no: idx + 1,
|
||
match_type: 'auto',
|
||
confidence: c.score,
|
||
matched_keyword: c.hit || ''
|
||
})
|
||
})
|
||
})
|
||
return { links, unmatched }
|
||
}
|
||
|
||
// === WAVE Crawler & AI ===
|
||
const waveCrawlerState = {
|
||
isRunning: false,
|
||
currentPage: 1,
|
||
manualQueue: [], // 存放手动上传的 URL 队列
|
||
logs: []
|
||
}
|
||
const waveImageRoot = path.join(process.cwd(), 'public', 'images', 'wave_references')
|
||
if (!fs.existsSync(waveImageRoot)) fs.mkdirSync(waveImageRoot, { recursive: true })
|
||
|
||
const logWave = msg => {
|
||
console.log('[WAVE]', msg)
|
||
waveCrawlerState.logs.push(`[${new Date().toISOString()}] ${msg}`)
|
||
if (waveCrawlerState.logs.length > 50) waveCrawlerState.logs.shift()
|
||
}
|
||
|
||
// === KOREAN Crawler & AI ===
|
||
const koreanCrawlerState = {
|
||
isRunning: false,
|
||
currentPage: 1,
|
||
manualQueue: [],
|
||
logs: []
|
||
}
|
||
const koreanImageRoot = path.join(process.cwd(), 'public', 'images', 'korean_references')
|
||
if (!fs.existsSync(koreanImageRoot)) fs.mkdirSync(koreanImageRoot, { recursive: true })
|
||
|
||
const logKorean = msg => {
|
||
console.log('[KOREAN]', msg)
|
||
koreanCrawlerState.logs.push(`[${new Date().toISOString()}] ${msg}`)
|
||
if (koreanCrawlerState.logs.length > 50) koreanCrawlerState.logs.shift()
|
||
}
|
||
|
||
const callVisionKoreanJudge = async (dataUrl) => {
|
||
const ai = getAiConfig()
|
||
if (!ai.enabled || !ai.apiKey) return { approved: false, reason: 'ai_disabled' }
|
||
|
||
const systemPrompt = [
|
||
'你是资深的男装时尚买手与图像审核专家。',
|
||
'当前需要判断该男士穿搭图片是否适合【韩式男装穿搭参考:简约、高级感、清爽、通勤或周末适用】的标准。',
|
||
'要求:',
|
||
'1. 必须是【男性服装】或【男性模特】穿着展示。',
|
||
'2. 图片质量要求清晰,服装主体完整可见。',
|
||
'3. 穿搭要体现韩式审美:比如大衣、风衣、毛衣、衬衫的合理叠穿,干净利落的裤型,避免过度花哨和街头风,侧重气质与质感。',
|
||
'必须严格输出JSON格式,不要包含任何解释或Markdown符号:',
|
||
'{',
|
||
' "approved": true或false,',
|
||
' "reason": "如果不合格,给出简短淘汰理由",',
|
||
' "evaluation": "如果合格,给出穿搭评价,分析色彩、材质、层次等韩式亮点(100字左右)",',
|
||
' "notes": "如果合格,给出日常借鉴与注意事项(比如内搭处理、版型选择等,100字左右)"',
|
||
'}'
|
||
].join('\n')
|
||
|
||
const payload = {
|
||
model: ai.visionModel,
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: '请审核图片是否符合韩式穿搭合格,直接输出JSON' },
|
||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||
]
|
||
}
|
||
],
|
||
temperature: 0.1
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(ai.apiBase, payload, {
|
||
timeout: ai.timeoutMs * 2,
|
||
headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' }
|
||
})
|
||
const content = response.data?.choices?.[0]?.message?.content || ''
|
||
const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content)
|
||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||
if (jsonMatch) {
|
||
return JSON.parse(jsonMatch[0])
|
||
}
|
||
} catch(e) {}
|
||
return { approved: false, reason: 'api_error_or_parse_fail' }
|
||
}
|
||
|
||
const callVisionWaveJudge = async (dataUrl) => {
|
||
const ai = getAiConfig()
|
||
if (!ai.enabled || !ai.apiKey) return { approved: false, reason: 'ai_disabled' }
|
||
|
||
const systemPrompt = [
|
||
'你是资深的男装时尚买手与图像审核专家。',
|
||
'当前需要判断该男士穿搭图片是否适合【50岁按40岁时尚审美:懂流行、有表达、不另类、通勤适用、不能装嫩】的标准。',
|
||
'要求:',
|
||
'1. 必须是【男性服装】或【男性模特】穿着展示。',
|
||
'2. 图片质量要求清晰,服装主体完整可见。',
|
||
'3. 穿搭不能过于花哨、另类或街头装嫩,也不能太老气古板,要体现质感、层次与成熟男士的得体表达。',
|
||
'必须严格输出JSON格式,不要包含任何解释或Markdown符号:',
|
||
'{',
|
||
' "approved": true或false,',
|
||
' "reason": "如果不合格,给出简短淘汰理由",',
|
||
' "evaluation": "如果合格,给出穿搭评价,分析色彩、材质、层次等亮点(100字左右)",',
|
||
' "notes": "如果合格,给出日常借鉴与注意事项(比如裤长、内搭处理等,100字左右)"',
|
||
'}'
|
||
].join('\n')
|
||
|
||
const payload = {
|
||
model: ai.visionModel,
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: '请审核图片是否合格,直接输出JSON' },
|
||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||
]
|
||
}
|
||
],
|
||
temperature: 0.1
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(ai.apiBase, payload, {
|
||
timeout: ai.timeoutMs * 2,
|
||
headers: { Authorization: `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json' }
|
||
})
|
||
const content = response.data?.choices?.[0]?.message?.content || ''
|
||
const text = Array.isArray(content) ? content.map(x => x.text || '').join('') : String(content)
|
||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||
if (jsonMatch) {
|
||
return JSON.parse(jsonMatch[0])
|
||
}
|
||
} catch(e) {}
|
||
return { approved: false, reason: 'api_error_or_parse_fail' }
|
||
}
|
||
|
||
const startWaveCrawler = async () => {
|
||
if (waveCrawlerState.isRunning) return
|
||
waveCrawlerState.isRunning = true
|
||
logWave('WAVE Crawler started')
|
||
while (waveCrawlerState.isRunning) {
|
||
try {
|
||
let uniqueUrls = []
|
||
|
||
// 优先处理手动上传的队列
|
||
if (waveCrawlerState.manualQueue.length > 0) {
|
||
logWave(`Processing manual queue, remaining: ${waveCrawlerState.manualQueue.length}`)
|
||
// 每次取10张处理,避免单次循环太长
|
||
uniqueUrls = waveCrawlerState.manualQueue.splice(0, 10)
|
||
} else {
|
||
const url = `https://wear.jp/men-coordinate/?pageno=${waveCrawlerState.currentPage}`
|
||
logWave(`Fetching page: ${url}`)
|
||
const res = await axios.get(url, {
|
||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' },
|
||
timeout: 15000
|
||
})
|
||
const html = res.data
|
||
// WEAR updated their image CDN to images.wear2.jp
|
||
const matches = html.match(/https:\/\/images\.wear2\.jp\/coordinate\/[^"']+_1000\.jpg/ig) || html.match(/https:\/\/images\.wear2\.jp\/coordinate\/[^"']+\.jpg/ig)
|
||
if (!matches || matches.length === 0) {
|
||
logWave('No images found on page, waiting 5 mins before retry...')
|
||
await delay(300000)
|
||
continue
|
||
}
|
||
// Prefer high-res _1000.jpg versions
|
||
uniqueUrls = [...new Set(matches)]
|
||
uniqueUrls = uniqueUrls.filter(u => u.includes('_1000.jpg') || !uniqueUrls.some(other => other !== u && other.replace('_1000', '') === u))
|
||
logWave(`Found ${uniqueUrls.length} images on page ${waveCrawlerState.currentPage}`)
|
||
}
|
||
|
||
for (const imgUrl of uniqueUrls) {
|
||
if (!waveCrawlerState.isRunning) break
|
||
|
||
const exists = db.prepare(`SELECT id FROM wave_references WHERE source_url=?`).get(imgUrl)
|
||
if (exists) {
|
||
logWave(`Skip already crawled: ${imgUrl}`)
|
||
continue
|
||
}
|
||
|
||
logWave(`Processing image: ${imgUrl}`)
|
||
|
||
// Delay 60s - 120s to be gentle
|
||
const waitMs = 60000 + Math.floor(Math.random() * 60000)
|
||
logWave(`Sleeping for ${Math.round(waitMs/1000)}s...`)
|
||
await delay(waitMs)
|
||
if (!waveCrawlerState.isRunning) break
|
||
|
||
let buf
|
||
try {
|
||
const imgRes = await axios.get(imgUrl, { responseType: 'arraybuffer', timeout: 10000 })
|
||
buf = Buffer.from(imgRes.data)
|
||
} catch(e) {
|
||
logWave(`Failed to download ${imgUrl}: ${e.message}`)
|
||
continue
|
||
}
|
||
|
||
const b64 = buf.toString('base64')
|
||
const dataUrl = `data:image/jpeg;base64,${b64}`
|
||
const aiRes = await callVisionWaveJudge(dataUrl)
|
||
if (!aiRes || !aiRes.approved) {
|
||
logWave(`AI Rejected: ${aiRes ? aiRes.reason : 'unknown'}`)
|
||
db.prepare(`INSERT OR IGNORE INTO wave_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run('REJECTED', imgUrl, aiRes ? aiRes.reason : 'reject', '')
|
||
continue
|
||
}
|
||
|
||
logWave(`AI Approved! Saving...`)
|
||
const fileName = `wave_${Date.now()}_${Math.floor(Math.random()*1000)}.jpg`
|
||
const localPath = `/images/wave_references/${fileName}`
|
||
fs.writeFileSync(path.join(waveImageRoot, fileName), buf)
|
||
|
||
db.prepare(`INSERT OR IGNORE INTO wave_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run(localPath, imgUrl, aiRes.evaluation || '', aiRes.notes || '')
|
||
}
|
||
|
||
// 如果不是在处理手动队列,才翻页
|
||
if (waveCrawlerState.manualQueue.length === 0) {
|
||
waveCrawlerState.currentPage += 1
|
||
}
|
||
} catch(e) {
|
||
logWave(`Error in crawler loop: ${e.message}`)
|
||
await delay(60000)
|
||
}
|
||
}
|
||
logWave('WAVE Crawler stopped')
|
||
}
|
||
|
||
const startKoreanCrawler = async () => {
|
||
if (koreanCrawlerState.isRunning) return
|
||
koreanCrawlerState.isRunning = true
|
||
logKorean('KOREAN Crawler started')
|
||
while (koreanCrawlerState.isRunning) {
|
||
try {
|
||
let uniqueUrls = []
|
||
|
||
if (koreanCrawlerState.manualQueue.length > 0) {
|
||
logKorean(`Processing manual queue, remaining: ${koreanCrawlerState.manualQueue.length}`)
|
||
uniqueUrls = koreanCrawlerState.manualQueue.splice(0, 10)
|
||
} else {
|
||
logKorean(`Fetching page: ${koreanCrawlerState.currentPage} from onthelook`)
|
||
|
||
const query = `
|
||
query HomeQueryV2($gender: [String], $styleTagIds: [Int], $season: [String], $page: Int, $perPage: Int) {
|
||
result: Posts(
|
||
filter: {styleTagIds: $styleTagIds, isHome: true, status: "ACTIVE", gender: $gender, season: $season}
|
||
page: $page
|
||
perPage: $perPage
|
||
) {
|
||
items {
|
||
id
|
||
imageUrl
|
||
detail
|
||
}
|
||
}
|
||
}
|
||
`;
|
||
const variables = {
|
||
"styleTagIds": [27,43,4,1],
|
||
"gender": ["MEN"],
|
||
"height": [],
|
||
"weight": [],
|
||
"season": ["SPRING","SUMMER","WINTER","FALL"],
|
||
"page": koreanCrawlerState.currentPage,
|
||
"perPage": 20
|
||
};
|
||
|
||
const res = await axios.post('https://api.onthelook.co.kr/graphql', { query, variables }, {
|
||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' },
|
||
timeout: 15000
|
||
})
|
||
|
||
const items = res.data?.data?.result?.items || []
|
||
if (!items || items.length === 0) {
|
||
logKorean('No images found on page, waiting 5 mins before retry...')
|
||
await delay(300000)
|
||
continue
|
||
}
|
||
uniqueUrls = items.map(item => item.imageUrl).filter(Boolean)
|
||
logKorean(`Found ${uniqueUrls.length} images on page ${koreanCrawlerState.currentPage}`)
|
||
}
|
||
|
||
for (const imgUrl of uniqueUrls) {
|
||
if (!koreanCrawlerState.isRunning) break
|
||
|
||
const exists = db.prepare(`SELECT id FROM korean_references WHERE source_url=?`).get(imgUrl)
|
||
if (exists) {
|
||
logKorean(`Skip already crawled: ${imgUrl}`)
|
||
continue
|
||
}
|
||
|
||
logKorean(`Processing image: ${imgUrl}`)
|
||
|
||
// Delay 60s - 120s to be gentle
|
||
const waitMs = 60000 + Math.floor(Math.random() * 60000)
|
||
logKorean(`Sleeping for ${Math.round(waitMs/1000)}s...`)
|
||
await delay(waitMs)
|
||
if (!koreanCrawlerState.isRunning) break
|
||
|
||
let buf
|
||
try {
|
||
const imgRes = await axios.get(imgUrl, { responseType: 'arraybuffer', timeout: 10000 })
|
||
buf = Buffer.from(imgRes.data)
|
||
} catch(e) {
|
||
logKorean(`Failed to download ${imgUrl}: ${e.message}`)
|
||
continue
|
||
}
|
||
|
||
const b64 = buf.toString('base64')
|
||
const dataUrl = `data:image/jpeg;base64,${b64}`
|
||
const aiRes = await callVisionKoreanJudge(dataUrl)
|
||
if (!aiRes || !aiRes.approved) {
|
||
logKorean(`AI Rejected: ${aiRes ? aiRes.reason : 'unknown'}`)
|
||
db.prepare(`INSERT OR IGNORE INTO korean_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run('REJECTED', imgUrl, aiRes ? aiRes.reason : 'reject', '')
|
||
continue
|
||
}
|
||
|
||
logKorean(`AI Approved! Saving...`)
|
||
const fileName = `korean_${Date.now()}_${Math.floor(Math.random()*1000)}.jpg`
|
||
const localPath = `/images/korean_references/${fileName}`
|
||
fs.writeFileSync(path.join(koreanImageRoot, fileName), buf)
|
||
|
||
db.prepare(`INSERT OR IGNORE INTO korean_references (image_url, source_url, evaluation, notes) VALUES (?, ?, ?, ?)`).run(localPath, imgUrl, aiRes.evaluation || '', aiRes.notes || '')
|
||
}
|
||
|
||
if (koreanCrawlerState.manualQueue.length === 0) {
|
||
koreanCrawlerState.currentPage += 1
|
||
}
|
||
} catch(e) {
|
||
logKorean(`Error in crawler loop: ${e.message}`)
|
||
await delay(60000)
|
||
}
|
||
}
|
||
logKorean('KOREAN Crawler stopped')
|
||
}
|
||
|
||
const rebuildLinksForOutfitId = outfitId => {
|
||
const outfit = db.prepare(`
|
||
SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, profile_key
|
||
FROM outfit_templates
|
||
WHERE id=? AND active_flag=1
|
||
`).get(outfitId)
|
||
db.prepare(`DELETE FROM outfit_item_links WHERE outfit_id=?`).run(outfitId)
|
||
if (!outfit) return { links: [], unmatched: [] }
|
||
const items = listActiveLibraryItems(outfit.profile_key)
|
||
const result = evaluateOutfit(outfit, items)
|
||
const stmt = db.prepare(`
|
||
INSERT OR IGNORE INTO outfit_item_links
|
||
(outfit_id, item_id, slot, rank_no, match_type, confidence, matched_keyword)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`)
|
||
result.links.forEach(r => stmt.run(r.outfit_id, r.item_id, r.slot, r.rank_no, r.match_type, r.confidence, r.matched_keyword))
|
||
return result
|
||
}
|
||
|
||
const rebuildAllOutfitLinks = (profileKey = 'mature_relaxed') => {
|
||
const outfits = db.prepare(`
|
||
SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, profile_key
|
||
FROM outfit_templates
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY id ASC
|
||
`).all(profileKey)
|
||
const items = listActiveLibraryItems(profileKey)
|
||
const tx = db.transaction(() => {
|
||
db.prepare(`
|
||
DELETE FROM outfit_item_links
|
||
WHERE outfit_id IN (SELECT id FROM outfit_templates WHERE profile_key=?)
|
||
`).run(profileKey)
|
||
const stmt = db.prepare(`
|
||
INSERT OR IGNORE INTO outfit_item_links
|
||
(outfit_id, item_id, slot, rank_no, match_type, confidence, matched_keyword)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`)
|
||
const unmatched = []
|
||
let matchedCount = 0
|
||
outfits.forEach(outfit => {
|
||
const r = evaluateOutfit(outfit, items)
|
||
r.links.forEach(one => {
|
||
stmt.run(one.outfit_id, one.item_id, one.slot, one.rank_no, one.match_type, one.confidence, one.matched_keyword)
|
||
matchedCount += 1
|
||
})
|
||
unmatched.push(...r.unmatched)
|
||
})
|
||
return { unmatched, matchedCount, outfitCount: outfits.length }
|
||
})
|
||
return tx()
|
||
}
|
||
|
||
if (needRebuildFashionLinks) {
|
||
try { rebuildAllOutfitLinks('mature_fashion40') } catch {}
|
||
}
|
||
|
||
const readUnmatchedReport = (profileKey = 'mature_relaxed') => {
|
||
const outfits = db.prepare(`
|
||
SELECT id, name, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc
|
||
FROM outfit_templates
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY id ASC
|
||
`).all(profileKey)
|
||
const items = listActiveLibraryItems(profileKey)
|
||
const rows = []
|
||
outfits.forEach(outfit => {
|
||
const r = evaluateOutfit(outfit, items)
|
||
rows.push(...r.unmatched)
|
||
})
|
||
return rows
|
||
}
|
||
|
||
const toBool = v => {
|
||
if (v === true) return true
|
||
const s = String(v || '').trim().toLowerCase()
|
||
return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === '是'
|
||
}
|
||
|
||
const anyText = v => String(v == null ? '' : v).trim()
|
||
|
||
const extractJson = text => {
|
||
const s = String(text || '').trim()
|
||
if (!s) return null
|
||
try { return JSON.parse(s) } catch {}
|
||
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)
|
||
if (fenced && fenced[1]) {
|
||
try { return JSON.parse(fenced[1]) } catch {}
|
||
}
|
||
const start = s.indexOf('{')
|
||
const end = s.lastIndexOf('}')
|
||
if (start >= 0 && end > start) {
|
||
const body = s.slice(start, end + 1)
|
||
try { return JSON.parse(body) } catch {}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const getAiConfig = () => {
|
||
const cfg = readConfig()
|
||
const ai = (cfg && cfg.ai && typeof cfg.ai === 'object') ? cfg.ai : {}
|
||
const envKey = String(process.env.DASHSCOPE_API_KEY || process.env.OPENAI_API_KEY || '').trim()
|
||
return {
|
||
enabled: ai.enabled !== false,
|
||
apiKey: String(ai.api_key || envKey || '').trim(),
|
||
apiBase: String(ai.api_base || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').trim(),
|
||
visionModel: String(ai.vision_model || 'qwen-vl-plus').trim(),
|
||
timeoutMs: Math.max(5000, Math.min(90000, Number(ai.timeout_ms || 30000)))
|
||
}
|
||
}
|
||
|
||
const normalizeFeatures = raw => {
|
||
const f = raw && typeof raw === 'object' ? raw : {}
|
||
const items = (f.items && typeof f.items === 'object') ? f.items : {}
|
||
const top = (items.top && typeof items.top === 'object') ? items.top : {}
|
||
const bottom = (items.bottom && typeof items.bottom === 'object') ? items.bottom : {}
|
||
const shoes = (items.shoes && typeof items.shoes === 'object') ? items.shoes : {}
|
||
const outer = (items.outer && typeof items.outer === 'object') ? items.outer : {}
|
||
const accessory = (items.accessory && typeof items.accessory === 'object') ? items.accessory : {}
|
||
const overall = (f.overall && typeof f.overall === 'object') ? f.overall : {}
|
||
const inferVisible = part => {
|
||
const explicit = part && Object.prototype.hasOwnProperty.call(part, 'visible') ? part.visible : undefined
|
||
if (explicit !== undefined) return toBool(explicit)
|
||
const d = anyText(part && (part.desc || part.name)).toLowerCase()
|
||
if (!d) return false
|
||
if (['未知', '不明', '未见', '遮挡', '看不见', 'unknown', 'not visible', 'unseen'].some(k => d.includes(k))) return false
|
||
return true
|
||
}
|
||
return {
|
||
items: {
|
||
top: {
|
||
desc: anyText(top.desc || top.name),
|
||
visible: inferVisible(top),
|
||
color: anyText(top.color),
|
||
fit: anyText(top.fit),
|
||
logo_level: anyText(top.logo_level),
|
||
pattern_level: anyText(top.pattern_level),
|
||
fluorescent: toBool(top.fluorescent),
|
||
shoulder: anyText(top.shoulder),
|
||
length: anyText(top.length),
|
||
collar: anyText(top.collar)
|
||
},
|
||
bottom: {
|
||
desc: anyText(bottom.desc || bottom.name),
|
||
visible: inferVisible(bottom),
|
||
color: anyText(bottom.color),
|
||
fit: anyText(bottom.fit),
|
||
logo_level: anyText(bottom.logo_level),
|
||
pattern_level: anyText(bottom.pattern_level),
|
||
fluorescent: toBool(bottom.fluorescent),
|
||
hem_stacking: toBool(bottom.hem_stacking),
|
||
too_tight: toBool(bottom.too_tight),
|
||
sagging: toBool(bottom.sagging)
|
||
},
|
||
shoes: {
|
||
desc: anyText(shoes.desc || shoes.name),
|
||
visible: inferVisible(shoes),
|
||
color: anyText(shoes.color),
|
||
fit: anyText(shoes.fit),
|
||
logo_level: anyText(shoes.logo_level),
|
||
pattern_level: anyText(shoes.pattern_level),
|
||
fluorescent: toBool(shoes.fluorescent),
|
||
clean: toBool(shoes.clean),
|
||
bulky: toBool(shoes.bulky)
|
||
},
|
||
outer: {
|
||
desc: anyText(outer.desc || outer.name),
|
||
visible: inferVisible(outer),
|
||
color: anyText(outer.color),
|
||
fit: anyText(outer.fit),
|
||
logo_level: anyText(outer.logo_level),
|
||
pattern_level: anyText(outer.pattern_level),
|
||
fluorescent: toBool(outer.fluorescent)
|
||
},
|
||
accessory: {
|
||
desc: anyText(accessory.desc || accessory.name),
|
||
visible: inferVisible(accessory),
|
||
color: anyText(accessory.color),
|
||
fit: anyText(accessory.fit),
|
||
logo_level: anyText(accessory.logo_level),
|
||
pattern_level: anyText(accessory.pattern_level),
|
||
fluorescent: toBool(accessory.fluorescent)
|
||
}
|
||
},
|
||
overall: {
|
||
color_count: Number.isFinite(Number(overall.color_count)) ? Number(overall.color_count) : 0,
|
||
big_logo: toBool(overall.big_logo),
|
||
bold_pattern: toBool(overall.bold_pattern),
|
||
fluorescent: toBool(overall.fluorescent),
|
||
vibe: anyText(overall.vibe)
|
||
}
|
||
}
|
||
}
|
||
|
||
const parseVisionResponse = (data, fallbackImageDataUrl) => {
|
||
const content = data && data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''
|
||
const rawText = Array.isArray(content)
|
||
? content.map(x => (x && typeof x === 'object' ? String(x.text || '') : String(x || ''))).join('\n')
|
||
: String(content || '')
|
||
const parsed = extractJson(rawText)
|
||
if (parsed) return normalizeFeatures(parsed)
|
||
return normalizeFeatures({
|
||
items: {
|
||
top: { desc: '' },
|
||
bottom: { desc: '' },
|
||
shoes: { desc: '' },
|
||
outer: { desc: '' },
|
||
accessory: { desc: '' }
|
||
},
|
||
overall: {
|
||
color_count: 0,
|
||
vibe: 'unknown'
|
||
},
|
||
source: fallbackImageDataUrl ? 'fallback_empty_parse' : 'fallback_no_image'
|
||
})
|
||
}
|
||
|
||
const callVisionExtract = async imageDataUrl => {
|
||
const ai = getAiConfig()
|
||
if (!ai.enabled) throw new Error('ai_disabled')
|
||
if (!ai.apiKey) throw new Error(`dashscope_api_key_missing
|
||
请配置以下任一方式:
|
||
1) 环境变量:DASHSCOPE_API_KEY
|
||
2) 文件配置:${configPath} 的 ai.api_key`)
|
||
const systemPrompt = [
|
||
'你是穿搭图像结构化识别器。',
|
||
'必须严格输出JSON,不要输出任何解释。',
|
||
'JSON字段:items.top/bottom/shoes/outer/accessory,每个对象至少包含 desc,color,fit,logo_level,pattern_level,fluorescent。',
|
||
'bottom 额外包含 hem_stacking,too_tight,sagging;top 额外包含 shoulder,length,collar;shoes 额外包含 clean,bulky。',
|
||
'overall包含 color_count,big_logo,bold_pattern,fluorescent,vibe。',
|
||
'logo_level取值建议:none/small/large;pattern_level取值建议:none/mild/bold;vibe取值建议:clean_energetic/neutral/mismatch。'
|
||
].join('\n')
|
||
const userPrompt = '请识别该穿搭照片并返回指定JSON。'
|
||
const payload = {
|
||
model: ai.visionModel,
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: userPrompt },
|
||
{ type: 'image_url', image_url: { url: imageDataUrl } }
|
||
]
|
||
}
|
||
],
|
||
temperature: 0.1
|
||
}
|
||
const response = await axios.post(ai.apiBase, payload, {
|
||
timeout: ai.timeoutMs,
|
||
headers: {
|
||
Authorization: `Bearer ${ai.apiKey}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
return parseVisionResponse(response && response.data, imageDataUrl)
|
||
}
|
||
|
||
const containsAny = (text, arr) => {
|
||
const t = String(text || '').toLowerCase()
|
||
return arr.some(k => t.includes(k))
|
||
}
|
||
|
||
const evaluateRules = (features, profileKey = 'mature_relaxed') => {
|
||
const rules = []
|
||
const pushRule = (id, title, pass, detail, weight, applicable = true) => rules.push({ id, title, pass, detail, weight, applicable })
|
||
const colorCount = Number(features.overall.color_count || 0)
|
||
const allItems = [features.items.top, features.items.bottom, features.items.shoes, features.items.outer, features.items.accessory]
|
||
const topVisible = toBool(features.items.top.visible)
|
||
const bottomVisible = toBool(features.items.bottom.visible)
|
||
const shoesVisible = toBool(features.items.shoes.visible)
|
||
const hasLargeLogo = features.overall.big_logo || allItems.some(x => containsAny(x.logo_level, ['large', 'big', '明显', '大']))
|
||
const hasBoldPattern = features.overall.bold_pattern || allItems.some(x => containsAny(x.pattern_level, ['bold', '夸张']))
|
||
const hasFluorescent = features.overall.fluorescent || allItems.some(x => toBool(x.fluorescent))
|
||
const shoulderBad = containsAny(features.items.top.shoulder, ['sloped', 'uneven', '歪', '塌'])
|
||
const overHip = containsAny(features.items.top.length, ['over_hip', 'overhip', '过臀', '太长'])
|
||
const hemStacking = toBool(features.items.bottom.hem_stacking)
|
||
const fitBad = containsAny(features.items.top.fit, ['tight', 'oversize', '过紧', '过松'])
|
||
const collarBad = containsAny(features.items.top.collar, ['deformed', '变形', '皱'])
|
||
const bottomBad = toBool(features.items.bottom.too_tight) || toBool(features.items.bottom.sagging)
|
||
const shoesBad = !toBool(features.items.shoes.clean) || toBool(features.items.shoes.bulky)
|
||
const vibeBad = containsAny(features.overall.vibe, ['mismatch', '老气', '装嫩']) || String(features.overall.vibe || '').trim() === ''
|
||
const colorLimit = profileKey === 'mature_fashion40' ? 4 : 3
|
||
pushRule('r1', profileKey === 'mature_fashion40' ? '颜色不超过4种并保留时尚点缀' : '颜色不超过3种', colorCount > 0 ? colorCount <= colorLimit : true, `识别颜色数量:${colorCount || '未知'}`, 15)
|
||
pushRule('r2', '全身无大logo、无夸张印花、无荧光色', !(hasLargeLogo || hasBoldPattern || hasFluorescent), `大logo:${hasLargeLogo} 印花夸张:${hasBoldPattern} 荧光:${hasFluorescent}`, 12)
|
||
pushRule('r3', '肩线平整、衣长不过臀、裤脚不堆褶', !(shoulderBad || overHip || hemStacking), `肩线异常:${shoulderBad} 过臀:${overHip} 裤脚堆褶:${hemStacking}`, 15, topVisible || bottomVisible)
|
||
pushRule('r4', '上衣合身不紧绷,领口不变形', !(fitBad || collarBad), `上衣版型异常:${fitBad} 领口异常:${collarBad}`, 10, topVisible)
|
||
pushRule('r5', '下装不紧身不垮裆,线条利落', !bottomBad, `下装问题:${bottomBad}`, 10, bottomVisible)
|
||
pushRule('r6', '鞋面干净,鞋型不笨重', !shoesBad, `鞋面或鞋型问题:${shoesBad}`, 10, shoesVisible)
|
||
pushRule('r7', profileKey === 'mature_fashion40' ? '整体像懂时尚的40岁审美:有表达、不另类' : '整体气质清爽干练,有活力不装嫩', !vibeBad, `整体气质判断:${features.overall.vibe || '未知'}`, 18)
|
||
const scoringRules = rules.filter(r => r.applicable !== false)
|
||
const full = scoringRules.reduce((s, r) => s + r.weight, 0) || 1
|
||
const got = scoringRules.filter(r => r.pass).reduce((s, r) => s + r.weight, 0)
|
||
const totalScore = Math.round(got * 100 / full)
|
||
const failRules = scoringRules.filter(r => !r.pass)
|
||
const level = totalScore >= 80 ? 'pass' : totalScore >= 60 ? 'warn' : 'fail'
|
||
return { total_score: totalScore, result_level: level, rules, fail_rules: failRules, scoring_rule_count: scoringRules.length }
|
||
}
|
||
|
||
const evaluateLibraryConflict = (features, tempZone, profileKey = 'mature_relaxed') => {
|
||
const pseudo = {
|
||
id: -1,
|
||
name: 'ai_audit',
|
||
temp_zone: tempZone || '12-22℃',
|
||
top_desc: features.items.top.desc,
|
||
bottom_desc: features.items.bottom.desc,
|
||
shoes_desc: features.items.shoes.desc,
|
||
outer_desc: features.items.outer.desc,
|
||
accessory_desc: features.items.accessory.desc
|
||
}
|
||
const items = listActiveLibraryItems(profileKey)
|
||
const evalRes = evaluateOutfit(pseudo, items)
|
||
const visibleBySlot = {
|
||
top: toBool(features.items.top.visible),
|
||
bottom: toBool(features.items.bottom.visible),
|
||
shoes: toBool(features.items.shoes.visible),
|
||
outer: toBool(features.items.outer.visible),
|
||
accessory: toBool(features.items.accessory.visible)
|
||
}
|
||
const visibleSlots = Object.keys(visibleBySlot).filter(k => visibleBySlot[k])
|
||
const unmatchedVisible = evalRes.unmatched.filter(u => visibleBySlot[String(u.slot || '')])
|
||
const topBySlot = {}
|
||
evalRes.links.forEach(r => {
|
||
if (!topBySlot[r.slot] || r.rank_no < topBySlot[r.slot].rank_no) topBySlot[r.slot] = r
|
||
})
|
||
const itemIds = Object.keys(topBySlot).map(k => Number(topBySlot[k].item_id)).filter(Boolean)
|
||
let similar = []
|
||
if (itemIds.length) {
|
||
const inClause = itemIds.map(() => '?').join(',')
|
||
similar = db.prepare(`
|
||
SELECT o.id, o.name, o.temp_zone, COUNT(1) AS overlap_count
|
||
FROM outfit_item_links l
|
||
JOIN outfit_templates o ON o.id = l.outfit_id
|
||
WHERE o.active_flag=1 AND o.profile_key=? AND l.item_id IN (${inClause})
|
||
GROUP BY o.id, o.name, o.temp_zone
|
||
ORDER BY overlap_count DESC, o.id DESC
|
||
LIMIT 3
|
||
`).all(profileKey, ...itemIds)
|
||
}
|
||
const duplicateLikely = similar.length > 0 && Number(similar[0].overlap_count || 0) >= 3
|
||
const conflictList = []
|
||
if (unmatchedVisible.length) {
|
||
unmatchedVisible.forEach(u => conflictList.push(`未匹配服饰库:${u.slot}=${u.text}`))
|
||
}
|
||
if (duplicateLikely) {
|
||
conflictList.push(`与已有搭配高度重复:${similar[0].name}`)
|
||
}
|
||
return {
|
||
duplicate_likely: duplicateLikely,
|
||
visible_slots: visibleSlots,
|
||
unmatched_visible_count: unmatchedVisible.length,
|
||
similar_outfits: similar,
|
||
unmatched: evalRes.unmatched,
|
||
unmatched_visible: unmatchedVisible,
|
||
linked: evalRes.links,
|
||
conflict_list: conflictList
|
||
}
|
||
}
|
||
|
||
const buildAuditSummary = (ruleResult, libResult) => {
|
||
if (ruleResult.result_level === 'pass' && !libResult.conflict_list.length) return '符合规则,且与服饰库无明显冲突'
|
||
const parts = []
|
||
if (ruleResult.fail_rules.length) parts.push(`规则未通过${ruleResult.fail_rules.length}项`)
|
||
if (libResult.unmatched_visible && libResult.unmatched_visible.length) parts.push(`可见部位库未匹配${libResult.unmatched_visible.length}项`)
|
||
if (libResult.duplicate_likely) parts.push('与现有搭配重复度较高')
|
||
return parts.join(',') || '请人工复核'
|
||
}
|
||
|
||
const ensureProfileLinks = profileKey => {
|
||
const c = db.prepare(`
|
||
SELECT COUNT(1) AS c
|
||
FROM outfit_item_links l
|
||
JOIN outfit_templates o ON o.id=l.outfit_id
|
||
WHERE o.active_flag=1 AND o.profile_key=?
|
||
`).get(profileKey)
|
||
const linkedCount = c && c.c ? Number(c.c) : 0
|
||
if (linkedCount > 0) return
|
||
try { rebuildAllOutfitLinks(profileKey) } catch {}
|
||
}
|
||
ensureProfileLinks('mature_relaxed')
|
||
ensureProfileLinks('mature_fashion40')
|
||
|
||
const bindRoutes = app => {
|
||
// 端口分离:8977 为自动化脚本实例(含爬虫),8976 为日常实例(仅数据查询)
|
||
const port = String(process.env.PORT || '8976')
|
||
const isAutomationInstance = (port === '8977')
|
||
|
||
app.use('/api/style_check', (req, res, next) => {
|
||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||
res.set('Pragma', 'no-cache')
|
||
res.set('Expires', '0')
|
||
next()
|
||
})
|
||
|
||
app.get('/api/style_check/ping', (req, res) => {
|
||
res.json({ ok: true, ts: Date.now() })
|
||
})
|
||
|
||
app.get('/api/style_check/profiles', (req, res) => {
|
||
try {
|
||
const rows = db.prepare(`SELECT profile_key, profile_name, slogan, theme FROM style_profiles ORDER BY profile_key ASC`).all()
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/profile', (req, res) => {
|
||
try {
|
||
const row = db.prepare(`SELECT * FROM user_profile WHERE id=1`).get() || {}
|
||
res.json({ ok: true, profile: row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.put('/api/style_check/profile', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
db.prepare(`
|
||
UPDATE user_profile SET
|
||
nickname=?,
|
||
city=?,
|
||
height_cm=?,
|
||
weight_kg=?,
|
||
style_keywords=?,
|
||
avoid_keywords=?,
|
||
updated_at=datetime('now')
|
||
WHERE id=1
|
||
`).run(
|
||
String(b.nickname || ''),
|
||
String(b.city || ''),
|
||
Number(b.height_cm || 0) || null,
|
||
Number(b.weight_kg || 0) || null,
|
||
String(b.style_keywords || ''),
|
||
String(b.avoid_keywords || '')
|
||
)
|
||
const row = db.prepare(`SELECT * FROM user_profile WHERE id=1`).get() || {}
|
||
res.json({ ok: true, profile: row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/rules', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const rows = db.prepare(`
|
||
SELECT id, rule_group, rule_text, weight, active_flag, sort_order
|
||
FROM mirror_rules
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY sort_order ASC, id ASC
|
||
`).all(profileKey)
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/ai/audit', async (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key)
|
||
const scene = normScene(b.scene)
|
||
const tempZone = normZone(b.temp_zone, b.temp_c)
|
||
const imageDataUrl = String(b.image_data_url || '').trim()
|
||
const hasImage = imageDataUrl.startsWith('data:image/')
|
||
const override = b.features_override && typeof b.features_override === 'object' ? b.features_override : null
|
||
if (!hasImage && !override) return res.status(400).json({ ok: false, error: 'image_or_features_required' })
|
||
const features = override ? normalizeFeatures(override) : await callVisionExtract(imageDataUrl)
|
||
const ruleResult = evaluateRules(features, profileKey)
|
||
const libraryResult = evaluateLibraryConflict(features, tempZone, profileKey)
|
||
const visibleCount = Number(libraryResult.visible_slots ? libraryResult.visible_slots.length : 0)
|
||
const unmatchedVisible = Number(libraryResult.unmatched_visible_count || 0)
|
||
const unmatchedRatio = visibleCount > 0 ? unmatchedVisible / visibleCount : 0
|
||
const hardLibraryFail = visibleCount >= 3 && unmatchedVisible >= 3 && unmatchedRatio >= 0.75
|
||
const finalLevel = ruleResult.result_level === 'fail' || hardLibraryFail
|
||
? 'fail'
|
||
: (ruleResult.result_level === 'warn' || libraryResult.conflict_list.length ? 'warn' : 'pass')
|
||
const summary = buildAuditSummary(ruleResult, libraryResult)
|
||
const score = Math.max(0, Math.min(100, Number(ruleResult.total_score || 0)))
|
||
const imageHash = hasImage ? crypto.createHash('sha1').update(imageDataUrl.slice(0, 2048)).digest('hex') : 'override'
|
||
db.prepare(`
|
||
INSERT INTO ai_audits (profile_key, scene, temp_zone, image_hash, total_score, result_level, summary, features_json, rules_json, library_json)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
profileKey,
|
||
scene,
|
||
tempZone,
|
||
imageHash,
|
||
score,
|
||
finalLevel,
|
||
summary,
|
||
JSON.stringify(features),
|
||
JSON.stringify(ruleResult),
|
||
JSON.stringify(libraryResult)
|
||
)
|
||
res.json({
|
||
ok: true,
|
||
report: {
|
||
profile_key: profileKey,
|
||
scene,
|
||
temp_zone: tempZone,
|
||
total_score: score,
|
||
result_level: finalLevel,
|
||
summary,
|
||
features,
|
||
rule_result: ruleResult,
|
||
library_result: libraryResult
|
||
}
|
||
})
|
||
} catch (e) {
|
||
const msg = String((e && e.message) || e)
|
||
res.status(500).json({ ok: false, error: msg })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/ai/audits/recent', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const limit = Math.max(1, Math.min(50, Number(req.query.limit || 10)))
|
||
const rows = db.prepare(`
|
||
SELECT id, profile_key, scene, temp_zone, image_hash, total_score, result_level, summary, features_json, rules_json, library_json, created_at
|
||
FROM ai_audits
|
||
WHERE profile_key=?
|
||
ORDER BY id DESC
|
||
LIMIT ?
|
||
`).all(profileKey, limit).map(r => ({
|
||
...r,
|
||
features: (() => { try { return JSON.parse(String(r.features_json || '{}')) } catch { return {} } })(),
|
||
rules: (() => { try { return JSON.parse(String(r.rules_json || '{}')) } catch { return {} } })(),
|
||
library: (() => { try { return JSON.parse(String(r.library_json || '{}')) } catch { return {} } })()
|
||
}))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/outfits', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const scene = String(req.query.scene || '').trim()
|
||
const tempZone = String(req.query.temp_zone || '').trim()
|
||
const q = String(req.query.q || '').trim().toLowerCase()
|
||
const includeLinks = String(req.query.include_links || '') === '1'
|
||
let rows = db.prepare(`
|
||
SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, active_flag, created_at, updated_at
|
||
FROM outfit_templates
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY id DESC
|
||
`).all(profileKey)
|
||
if (scene) rows = rows.filter(r => String(r.scene) === scene)
|
||
if (tempZone) rows = rows.filter(r => String(r.temp_zone) === tempZone)
|
||
if (q) {
|
||
rows = rows.filter(r => {
|
||
const s = [r.name, r.top_desc, r.bottom_desc, r.shoes_desc, r.outer_desc, r.accessory_desc, r.notes].map(v => String(v || '').toLowerCase()).join(' ')
|
||
return s.includes(q)
|
||
})
|
||
}
|
||
if (includeLinks) {
|
||
rows = rows.map(r => {
|
||
const linked = db.prepare(`
|
||
SELECT l.slot, l.rank_no, l.confidence, i.item_name
|
||
FROM outfit_item_links l
|
||
JOIN apparel_items i ON i.id = l.item_id
|
||
WHERE l.outfit_id=?
|
||
ORDER BY l.slot ASC, l.rank_no ASC
|
||
`).all(r.id)
|
||
const grouped = {}
|
||
linked.forEach(one => {
|
||
const k = String(one.slot || '')
|
||
if (!grouped[k]) grouped[k] = []
|
||
grouped[k].push(String(one.item_name || ''))
|
||
})
|
||
const linkedItemsSummary = Object.keys(grouped).map(k => `${k}:${grouped[k].join(' / ')}`).join(';')
|
||
return { ...r, linked_items: linked, linked_items_summary: linkedItemsSummary }
|
||
})
|
||
}
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/library/items', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const slot = String(req.query.slot || '').trim()
|
||
const tempZone = String(req.query.temp_zone || '').trim()
|
||
const q = String(req.query.q || '').trim().toLowerCase()
|
||
let rows = db.prepare(`
|
||
SELECT id, item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, updated_at
|
||
FROM apparel_items
|
||
WHERE active_flag=1 AND profile_key=?
|
||
ORDER BY slot ASC, id ASC
|
||
`).all(profileKey)
|
||
if (slot) rows = rows.filter(r => String(r.slot) === slot)
|
||
if (tempZone) rows = rows.filter(r => String(r.temp_zone) === tempZone || String(r.temp_zone) === 'ALL')
|
||
if (q) {
|
||
rows = rows.filter(r => {
|
||
const s = [r.item_name, r.category, r.brand_options, r.keywords, r.notes].map(v => String(v || '').toLowerCase()).join(' ')
|
||
return s.includes(q)
|
||
})
|
||
}
|
||
rows = rows.map(r => {
|
||
const images = listItemImages(Number(r.id), profileKey).filter(x => isLocalImageUrl(x.image_url))
|
||
return { ...r, image_count: images.length, image_preview: images.slice(0, 1).map(x => x.image_url)[0] || '' }
|
||
})
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/library/items/:id/images', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const itemId = Number(req.params.id)
|
||
if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' })
|
||
const item = db.prepare(`SELECT id FROM apparel_items WHERE id=? AND profile_key=? AND active_flag=1`).get(itemId, profileKey)
|
||
if (!item) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||
const rows = listItemImages(itemId, profileKey).filter(x => isLocalImageUrl(x.image_url))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/library/items', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key)
|
||
const itemName = String(b.item_name || '').trim()
|
||
const slot = String(b.slot || '').trim()
|
||
const category = String(b.category || '').trim()
|
||
const tempZone = String(b.temp_zone || 'ALL').trim() || 'ALL'
|
||
if (!itemName) return res.status(400).json({ ok: false, error: 'item_name_required' })
|
||
if (!slot) return res.status(400).json({ ok: false, error: 'slot_required' })
|
||
if (!category) return res.status(400).json({ ok: false, error: 'category_required' })
|
||
const info = db.prepare(`
|
||
INSERT INTO apparel_items
|
||
(item_name, slot, category, temp_zone, season_tag, brand_options, price_min, price_max, keywords, notes, profile_key, active_flag, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
|
||
`).run(
|
||
itemName,
|
||
slot,
|
||
category,
|
||
tempZone,
|
||
String(b.season_tag || ''),
|
||
String(b.brand_options || ''),
|
||
Number.isFinite(Number(b.price_min)) ? Number(b.price_min) : null,
|
||
Number.isFinite(Number(b.price_max)) ? Number(b.price_max) : null,
|
||
String(b.keywords || ''),
|
||
String(b.notes || ''),
|
||
profileKey
|
||
)
|
||
const itemId = Number(info.lastInsertRowid)
|
||
const row = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId)
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.put('/api/style_check/library/items/:id', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key || req.query.profile_key)
|
||
const itemId = Number(req.params.id)
|
||
if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' })
|
||
const current = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId)
|
||
if (!current) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||
if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' })
|
||
db.prepare(`
|
||
UPDATE apparel_items SET
|
||
item_name=?,
|
||
slot=?,
|
||
category=?,
|
||
temp_zone=?,
|
||
season_tag=?,
|
||
brand_options=?,
|
||
price_min=?,
|
||
price_max=?,
|
||
keywords=?,
|
||
notes=?,
|
||
updated_at=datetime('now')
|
||
WHERE id=?
|
||
`).run(
|
||
String(b.item_name == null ? current.item_name : b.item_name),
|
||
String(b.slot == null ? current.slot : b.slot),
|
||
String(b.category == null ? current.category : b.category),
|
||
String(b.temp_zone == null ? current.temp_zone : b.temp_zone),
|
||
String(b.season_tag == null ? current.season_tag : b.season_tag),
|
||
String(b.brand_options == null ? current.brand_options : b.brand_options),
|
||
Number.isFinite(Number(b.price_min)) ? Number(b.price_min) : current.price_min,
|
||
Number.isFinite(Number(b.price_max)) ? Number(b.price_max) : current.price_max,
|
||
String(b.keywords == null ? current.keywords : b.keywords),
|
||
String(b.notes == null ? current.notes : b.notes),
|
||
itemId
|
||
)
|
||
const row = db.prepare(`SELECT * FROM apparel_items WHERE id=?`).get(itemId)
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/library/items/:id/images', async (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key || req.query.profile_key)
|
||
const itemId = Number(req.params.id)
|
||
if (!Number.isFinite(itemId) || itemId <= 0) return res.status(400).json({ ok: false, error: 'invalid_item_id' })
|
||
const item = db.prepare(`SELECT id FROM apparel_items WHERE id=? AND profile_key=? AND active_flag=1`).get(itemId, profileKey)
|
||
if (!item) return res.status(404).json({ ok: false, error: 'item_not_found' })
|
||
const imageDataUrl = String(b.image_data_url || '').trim()
|
||
const directUrl = String(b.image_url || '').trim()
|
||
let imageUrl = ''
|
||
let sourceType = 'manual_url_fetch'
|
||
if (imageDataUrl) {
|
||
imageUrl = saveUploadedLibraryImage(profileKey, itemId, imageDataUrl)
|
||
sourceType = 'manual_upload'
|
||
} else if (directUrl.startsWith('http://') || directUrl.startsWith('https://')) {
|
||
imageUrl = await downloadImageToLocal(directUrl, profileKey, itemId, Number(b.sort_order || 999))
|
||
} else {
|
||
return res.status(400).json({ ok: false, error: 'image_data_url_or_image_url_required' })
|
||
}
|
||
const sortOrder = Math.max(1, Number(b.sort_order || 999))
|
||
const caption = String(b.caption || '').trim()
|
||
const info = db.prepare(`
|
||
INSERT INTO apparel_item_images (item_id, profile_key, image_url, source_type, caption, sort_order)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
`).run(itemId, profileKey, imageUrl, sourceType, caption, sortOrder)
|
||
const row = db.prepare(`SELECT * FROM apparel_item_images WHERE id=?`).get(info.lastInsertRowid)
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.delete('/api/style_check/library/images/:imageId', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key || (req.body || {}).profile_key)
|
||
const imageId = Number(req.params.imageId)
|
||
if (!Number.isFinite(imageId) || imageId <= 0) return res.status(400).json({ ok: false, error: 'invalid_image_id' })
|
||
const row = db.prepare(`SELECT * FROM apparel_item_images WHERE id=? AND profile_key=?`).get(imageId, profileKey)
|
||
if (!row) return res.status(404).json({ ok: false, error: 'image_not_found' })
|
||
db.prepare(`DELETE FROM apparel_item_images WHERE id=?`).run(imageId)
|
||
const url = String(row.image_url || '')
|
||
if (url.startsWith(`${localImageUrlPrefix}/`)) {
|
||
const abs = path.join(process.cwd(), 'public', url.replace(/^\//, '').replace(/\//g, path.sep))
|
||
if (fs.existsSync(abs)) {
|
||
try { fs.unlinkSync(abs) } catch {}
|
||
}
|
||
}
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/library/images/missing_report', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const rows = getImageMissingReport(profileKey)
|
||
res.json({ ok: true, profile_key: profileKey, missing_count: rows.length, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/library/images/fetch_local', async (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key)
|
||
const targetPerItem = Math.max(3, Math.min(8, Number(b.target_per_item || 5)))
|
||
const force = String(b.force || '1') !== '0'
|
||
const result = await ensureItemLocalImages(profileKey, targetPerItem, force, 0)
|
||
res.json({ ok: true, profile_key: profileKey, ...result })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// 爬虫控制接口 & 后台抓取:仅在自动化实例(8977)启用
|
||
if (isAutomationInstance) {
|
||
app.post('/api/style_check/library/images/bg_fetch/start', (req, res) => {
|
||
startBackgroundFetch()
|
||
res.json({ ok: true, message: 'Background fetch started' })
|
||
})
|
||
|
||
app.post('/api/style_check/library/images/bg_fetch/stop', (req, res) => {
|
||
stopBackgroundFetch()
|
||
res.json({ ok: true, message: 'Background fetch stopped' })
|
||
})
|
||
}
|
||
|
||
if (isAutomationInstance) {
|
||
app.get('/api/style_check/library/images/bg_fetch/status', (req, res) => {
|
||
res.json({ ok: true, is_running: isBackgroundFetchEnabled, report: getBgFetchReport() })
|
||
})
|
||
}
|
||
|
||
// Start background fetch automatically when the app starts (仅自动化实例)
|
||
if (isAutomationInstance) {
|
||
startBackgroundFetch()
|
||
}
|
||
|
||
app.post('/api/style_check/library/rebuild_links', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey((req.body || {}).profile_key)
|
||
const result = rebuildAllOutfitLinks(profileKey)
|
||
res.json({
|
||
ok: true,
|
||
profile_key: profileKey,
|
||
matched_count: Number(result.matchedCount || 0),
|
||
outfit_count: Number(result.outfitCount || 0),
|
||
unmatched_count: result.unmatched.length,
|
||
unmatched: result.unmatched
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/library/unmatched_report', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const rows = readUnmatchedReport(profileKey)
|
||
res.json({ ok: true, unmatched_count: rows.length, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/outfits', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key)
|
||
const name = String(b.name || '').trim()
|
||
if (!name) return res.status(400).json({ ok: false, error: 'name_required' })
|
||
const scene = normScene(b.scene)
|
||
const tempZone = normZone(b.temp_zone, b.temp_c)
|
||
const info = db.prepare(`
|
||
INSERT INTO outfit_templates
|
||
(name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes, profile_key, active_flag, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
|
||
`).run(
|
||
name,
|
||
scene,
|
||
tempZone,
|
||
String(b.top_desc || ''),
|
||
String(b.bottom_desc || ''),
|
||
String(b.shoes_desc || ''),
|
||
String(b.outer_desc || ''),
|
||
String(b.accessory_desc || ''),
|
||
String(b.notes || ''),
|
||
profileKey
|
||
)
|
||
const row = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(info.lastInsertRowid)
|
||
try { rebuildLinksForOutfitId(Number(info.lastInsertRowid)) } catch {}
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.put('/api/style_check/outfits/:id', (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id)
|
||
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key || req.query.profile_key || 'mature_relaxed')
|
||
const current = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(id)
|
||
if (!current) return res.status(404).json({ ok: false, error: 'not_found' })
|
||
if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' })
|
||
const name = String(b.name == null ? current.name : b.name).trim()
|
||
if (!name) return res.status(400).json({ ok: false, error: 'name_required' })
|
||
const scene = normScene(b.scene == null ? current.scene : b.scene)
|
||
const tempZone = normZone(b.temp_zone == null ? current.temp_zone : b.temp_zone, b.temp_c)
|
||
db.prepare(`
|
||
UPDATE outfit_templates SET
|
||
name=?,
|
||
scene=?,
|
||
temp_zone=?,
|
||
top_desc=?,
|
||
bottom_desc=?,
|
||
shoes_desc=?,
|
||
outer_desc=?,
|
||
accessory_desc=?,
|
||
notes=?,
|
||
updated_at=datetime('now')
|
||
WHERE id=?
|
||
`).run(
|
||
name,
|
||
scene,
|
||
tempZone,
|
||
String(b.top_desc == null ? current.top_desc : b.top_desc),
|
||
String(b.bottom_desc == null ? current.bottom_desc : b.bottom_desc),
|
||
String(b.shoes_desc == null ? current.shoes_desc : b.shoes_desc),
|
||
String(b.outer_desc == null ? current.outer_desc : b.outer_desc),
|
||
String(b.accessory_desc == null ? current.accessory_desc : b.accessory_desc),
|
||
String(b.notes == null ? current.notes : b.notes),
|
||
id
|
||
)
|
||
const row = db.prepare(`SELECT * FROM outfit_templates WHERE id=?`).get(id)
|
||
try { rebuildLinksForOutfitId(id) } catch {}
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.delete('/api/style_check/outfits/:id', (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id)
|
||
const profileKey = normProfileKey(req.query.profile_key || (req.body || {}).profile_key || 'mature_relaxed')
|
||
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ ok: false, error: 'invalid_id' })
|
||
const current = db.prepare(`SELECT profile_key FROM outfit_templates WHERE id=?`).get(id)
|
||
if (!current) return res.status(404).json({ ok: false, error: 'not_found' })
|
||
if (String(current.profile_key || 'mature_relaxed') !== profileKey) return res.status(403).json({ ok: false, error: 'profile_mismatch' })
|
||
db.prepare(`UPDATE outfit_templates SET active_flag=0, updated_at=datetime('now') WHERE id=?`).run(id)
|
||
db.prepare(`DELETE FROM outfit_item_links WHERE outfit_id=?`).run(id)
|
||
res.json({ ok: true })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/today', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const scene = normScene(req.query.scene)
|
||
const tempZone = normZone(req.query.temp_zone, req.query.temp_c)
|
||
const rows = db.prepare(`
|
||
SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes
|
||
FROM outfit_templates
|
||
WHERE active_flag=1 AND profile_key=? AND scene=? AND temp_zone=?
|
||
ORDER BY id ASC
|
||
`).all(profileKey, scene, tempZone)
|
||
const fallbacks = rows.length ? [] : db.prepare(`
|
||
SELECT id, name, scene, temp_zone, top_desc, bottom_desc, shoes_desc, outer_desc, accessory_desc, notes
|
||
FROM outfit_templates
|
||
WHERE active_flag=1 AND profile_key=? AND scene=?
|
||
ORDER BY id ASC
|
||
LIMIT 3
|
||
`).all(profileKey, scene)
|
||
const chosen = rows[0] || fallbacks[0] || null
|
||
res.json({
|
||
ok: true,
|
||
profile_key: profileKey,
|
||
scene,
|
||
temp_zone: tempZone,
|
||
chosen,
|
||
alternatives: (rows.length ? rows.slice(1, 4) : fallbacks.slice(1, 4))
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/checks', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
const profileKey = normProfileKey(b.profile_key)
|
||
const scene = normScene(b.scene)
|
||
const tempZone = normZone(b.temp_zone, b.temp_c)
|
||
const totalScore = Number.isFinite(Number(b.total_score)) ? Number(b.total_score) : 0
|
||
const resultLevel = String(b.result_level || (totalScore >= 80 ? 'pass' : totalScore >= 60 ? 'warn' : 'fail'))
|
||
const issues = Array.isArray(b.issues) ? b.issues.slice(0, 50) : []
|
||
const today = String((b.check_date || '')).trim() || new Date().toISOString().slice(0, 10)
|
||
const info = db.prepare(`
|
||
INSERT INTO mirror_checks (profile_key, check_date, scene, temp_zone, total_score, result_level, issues_json)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`).run(profileKey, today, scene, tempZone, totalScore, resultLevel, JSON.stringify(issues))
|
||
const row = db.prepare(`SELECT * FROM mirror_checks WHERE id=?`).get(info.lastInsertRowid)
|
||
res.json({ ok: true, row })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/style_check/checks/recent', (req, res) => {
|
||
try {
|
||
const profileKey = normProfileKey(req.query.profile_key)
|
||
const limit = Math.max(1, Math.min(60, Number(req.query.limit || 7)))
|
||
const rows = db.prepare(`
|
||
SELECT id, profile_key, check_date, scene, temp_zone, total_score, result_level, issues_json, created_at
|
||
FROM mirror_checks
|
||
WHERE profile_key=?
|
||
ORDER BY id DESC
|
||
LIMIT ?
|
||
`).all(profileKey, limit).map(r => ({
|
||
...r,
|
||
issues: (() => {
|
||
try {
|
||
const arr = JSON.parse(String(r.issues_json || '[]'))
|
||
return Array.isArray(arr) ? arr : []
|
||
} catch {
|
||
return []
|
||
}
|
||
})()
|
||
}))
|
||
res.json({ ok: true, rows })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
// === WAVE API ===
|
||
app.get('/api/style_check/wave', (req, res) => {
|
||
try {
|
||
const page = Math.max(1, parseInt(req.query.page || '1', 10))
|
||
const limit = Math.max(1, Math.min(500, parseInt(req.query.limit || '20', 10)))
|
||
const offset = (page - 1) * limit
|
||
const rows = db.prepare(`
|
||
SELECT id, image_url, source_url, evaluation, notes, created_at
|
||
FROM wave_references
|
||
WHERE image_url != 'REJECTED'
|
||
ORDER BY id DESC
|
||
LIMIT ? OFFSET ?
|
||
`).all(limit, offset)
|
||
const total = db.prepare(`SELECT count(*) as c FROM wave_references WHERE image_url != 'REJECTED'`).get().c
|
||
res.json({ ok: true, rows, total, page, limit })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
if (isAutomationInstance) {
|
||
app.get('/api/style_check/wave/status', (req, res) => {
|
||
try {
|
||
const totalProcessed = db.prepare(`SELECT count(*) as c FROM wave_references`).get().c
|
||
const totalApproved = db.prepare(`SELECT count(*) as c FROM wave_references WHERE image_url != 'REJECTED'`).get().c
|
||
res.json({
|
||
ok: true,
|
||
isRunning: waveCrawlerState.isRunning,
|
||
currentPage: waveCrawlerState.currentPage,
|
||
logs: waveCrawlerState.logs,
|
||
totalProcessed,
|
||
totalApproved
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
}
|
||
|
||
// wave 爬虫控制接口:仅在自动化实例(8977)启用
|
||
if (isAutomationInstance) {
|
||
app.post('/api/style_check/wave/toggle', (req, res) => {
|
||
try {
|
||
if (waveCrawlerState.isRunning) {
|
||
waveCrawlerState.isRunning = false
|
||
logWave('User requested stop')
|
||
} else {
|
||
startWaveCrawler()
|
||
}
|
||
res.json({ ok: true, isRunning: waveCrawlerState.isRunning })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/wave/upload_links', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
if (Array.isArray(b.links) && b.links.length > 0) {
|
||
waveCrawlerState.manualQueue.push(...b.links)
|
||
logWave(`Received ${b.links.length} manual links. Queue size: ${waveCrawlerState.manualQueue.length}`)
|
||
if (!waveCrawlerState.isRunning) {
|
||
startWaveCrawler()
|
||
}
|
||
}
|
||
res.json({ ok: true, queueLength: waveCrawlerState.manualQueue.length, isRunning: waveCrawlerState.isRunning })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
}
|
||
app.get('/api/style_check/korean', (req, res) => {
|
||
try {
|
||
const page = Math.max(1, parseInt(req.query.page || '1', 10))
|
||
const limit = Math.max(1, Math.min(500, parseInt(req.query.limit || '20', 10)))
|
||
const offset = (page - 1) * limit
|
||
const rows = db.prepare(`
|
||
SELECT id, image_url, source_url, evaluation, notes, created_at
|
||
FROM korean_references
|
||
WHERE image_url != 'REJECTED'
|
||
ORDER BY id DESC
|
||
LIMIT ? OFFSET ?
|
||
`).all(limit, offset)
|
||
const total = db.prepare(`SELECT count(*) as c FROM korean_references WHERE image_url != 'REJECTED'`).get().c
|
||
res.json({ ok: true, rows, total, page, limit })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
if (isAutomationInstance) {
|
||
app.get('/api/style_check/korean/status', (req, res) => {
|
||
try {
|
||
const totalProcessed = db.prepare(`SELECT count(*) as c FROM korean_references`).get().c
|
||
const totalApproved = db.prepare(`SELECT count(*) as c FROM korean_references WHERE image_url != 'REJECTED'`).get().c
|
||
res.json({
|
||
ok: true,
|
||
isRunning: koreanCrawlerState.isRunning,
|
||
currentPage: koreanCrawlerState.currentPage,
|
||
logs: koreanCrawlerState.logs,
|
||
totalProcessed,
|
||
totalApproved
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
}
|
||
|
||
// korean 爬虫控制接口:仅在自动化实例(8977)启用
|
||
if (isAutomationInstance) {
|
||
app.post('/api/style_check/korean/toggle', (req, res) => {
|
||
try {
|
||
if (koreanCrawlerState.isRunning) {
|
||
koreanCrawlerState.isRunning = false
|
||
logKorean('User requested stop')
|
||
} else {
|
||
startKoreanCrawler()
|
||
}
|
||
res.json({ ok: true, isRunning: koreanCrawlerState.isRunning })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.post('/api/style_check/korean/upload_links', (req, res) => {
|
||
try {
|
||
const b = req.body || {}
|
||
if (Array.isArray(b.links) && b.links.length > 0) {
|
||
koreanCrawlerState.manualQueue.push(...b.links)
|
||
logKorean(`Received ${b.links.length} manual links. Queue size: ${koreanCrawlerState.manualQueue.length}`)
|
||
if (!koreanCrawlerState.isRunning) {
|
||
startKoreanCrawler()
|
||
}
|
||
}
|
||
res.json({ ok: true, queueLength: koreanCrawlerState.manualQueue.length, isRunning: koreanCrawlerState.isRunning })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 非自动化实例(8976):将爬虫控制接口反向代理到 8977 自动化实例,避免前端跨域 / Mixed Content
|
||
if (!isAutomationInstance) {
|
||
const AUTOMATION_TARGET = process.env.AUTOMATION_TARGET || 'http://127.0.0.1:8977'
|
||
const proxyToAutomation = async (req, res) => {
|
||
try {
|
||
const r = await axios({
|
||
method: req.method,
|
||
url: AUTOMATION_TARGET + req.originalUrl,
|
||
data: req.body,
|
||
headers: { 'content-type': req.headers['content-type'] || 'application/json' },
|
||
timeout: 15000,
|
||
validateStatus: () => true,
|
||
responseType: 'json'
|
||
})
|
||
res.status(r.status).json(r.data)
|
||
} catch (e) {
|
||
res.status(502).json({ ok: false, error: '8977 自动化服务不可达: ' + String(e.message || e) })
|
||
}
|
||
}
|
||
// POST 接口:控制类
|
||
const automationPostRoutes = [
|
||
'/api/style_check/library/images/bg_fetch/start',
|
||
'/api/style_check/library/images/bg_fetch/stop',
|
||
'/api/style_check/wave/toggle',
|
||
'/api/style_check/wave/upload_links',
|
||
'/api/style_check/korean/toggle',
|
||
'/api/style_check/korean/upload_links'
|
||
]
|
||
automationPostRoutes.forEach(p => app.post(p, proxyToAutomation))
|
||
// GET 接口:状态类(isRunning/currentPage/logs 在 8977 进程内存,必须反代)
|
||
const automationGetRoutes = [
|
||
'/api/style_check/wave/status',
|
||
'/api/style_check/korean/status',
|
||
'/api/style_check/library/images/bg_fetch/status'
|
||
]
|
||
automationGetRoutes.forEach(p => app.get(p, proxyToAutomation))
|
||
}
|
||
}
|
||
|
||
module.exports = { bindRoutes }
|