Compare commits
3 Commits
de179c4c7c
...
67310c8fe7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67310c8fe7 | ||
|
|
e0f9656684 | ||
|
|
9b8b03e783 |
@ -352,9 +352,20 @@ export async function getFlexoptixSuggestions(switchId: string) {
|
||||
CASE WHEN t.price_verified_eur IS NOT NULL THEN 'EUR'
|
||||
ELSE (SELECT po.currency FROM price_observations po
|
||||
WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1)
|
||||
END AS latest_currency
|
||||
END AS latest_currency,
|
||||
so.warehouse_de_qty,
|
||||
so.warehouse_global_qty,
|
||||
so.backorder_qty,
|
||||
so.backorder_estimated_date
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON t.vendor_id = v.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT warehouse_de_qty, warehouse_global_qty, backorder_qty, backorder_estimated_date
|
||||
FROM stock_observations
|
||||
WHERE transceiver_id = t.id
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
) so ON true
|
||||
WHERE LOWER(v.name) = 'flexoptix'
|
||||
AND t.form_factor IN (
|
||||
SELECT form_factor FROM switch_form_factors WHERE form_factor IS NOT NULL
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { Router, Request, Response } from "express";
|
||||
import { pool } from "../db/client";
|
||||
import { setLlmProvider, getLlmProvider } from "../llm/client";
|
||||
import { setLlmProvider, getLlmProvider, refreshLlmAutoDiscovery } from "../llm/client";
|
||||
|
||||
/** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */
|
||||
const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>();
|
||||
@ -1347,10 +1347,12 @@ async function runLlmPipeline(
|
||||
const finalIssues = validateArticle(draftContent);
|
||||
|
||||
// Update the draft in DB (title updated to generated headline if available)
|
||||
const pipelineModel = getLlmProvider();
|
||||
const pipelineGeneratedBy = `fo-blog-engine-${pipelineModel.ollamaModel || pipelineModel.provider || "llm"}`;
|
||||
await pool.query(
|
||||
`UPDATE blog_drafts
|
||||
SET title = $9, draft_content = $1, word_count = $2,
|
||||
generated_by = 'fo-blog-engine-v7',
|
||||
generated_by = $10,
|
||||
pipeline_version = 'v7',
|
||||
pipeline_steps_completed = $3,
|
||||
auto_qa_score = $4,
|
||||
@ -1377,6 +1379,7 @@ async function runLlmPipeline(
|
||||
linkedinCharCount,
|
||||
draftId,
|
||||
finalTitle,
|
||||
pipelineGeneratedBy,
|
||||
],
|
||||
);
|
||||
|
||||
@ -1528,6 +1531,236 @@ blogRouter.post("/generate", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Fetch a URL and extract readable text content for use as LLM context.
|
||||
* Returns spaDetected=true when extracted body text is thin (< 300 chars),
|
||||
* indicating a JavaScript Single Page Application where content is rendered client-side.
|
||||
* In that case, metaDesc contains OG/meta description fallback text.
|
||||
*/
|
||||
async function fetchUrlContent(rawUrl: string): Promise<{
|
||||
pageTitle: string;
|
||||
text: string;
|
||||
spaDetected: boolean;
|
||||
metaDesc: string;
|
||||
}> {
|
||||
const response = await fetch(rawUrl, {
|
||||
headers: { "User-Agent": "TIPBot/1.0 blog-from-url (+https://tip.flexoptix.net)" },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("text/html") && !contentType.includes("text/plain") && !contentType.includes("application/xhtml")) {
|
||||
throw new Error(`Unsupported content type: ${contentType.split(";")[0]}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// --- Extract OG / meta tags for SPA fallback ---
|
||||
const decodeEntities = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/&#\d+;/g, "").replace(/&[a-z]+;/gi, " ").trim();
|
||||
|
||||
const ogTitle =
|
||||
html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']{1,200})["']/i)?.[1] ||
|
||||
html.match(/<meta[^>]+content=["']([^"']{1,200})["'][^>]+property=["']og:title["']/i)?.[1] || "";
|
||||
|
||||
const ogDesc =
|
||||
html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{1,500})["']/i)?.[1] ||
|
||||
html.match(/<meta[^>]+content=["']([^"']{1,500})["'][^>]+property=["']og:description["']/i)?.[1] ||
|
||||
html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{1,500})["']/i)?.[1] ||
|
||||
html.match(/<meta[^>]+content=["']([^"']{1,500})["'][^>]+name=["']description["']/i)?.[1] || "";
|
||||
|
||||
const ogSiteName =
|
||||
html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']{1,100})["']/i)?.[1] ||
|
||||
html.match(/<meta[^>]+content=["']([^"']{1,100})["'][^>]+property=["']og:site_name["']/i)?.[1] || "";
|
||||
|
||||
// Extract page title: prefer OG title, then <title>, then <h1>
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]{1,200})<\/title>/i);
|
||||
const h1Match = html.match(/<h1[^>]*>([^<]{1,150})<\/h1>/i);
|
||||
const pageTitle = decodeEntities(
|
||||
ogTitle || titleMatch?.[1] || h1Match?.[1] || ""
|
||||
);
|
||||
|
||||
const metaDesc = decodeEntities(ogDesc);
|
||||
|
||||
// Strip scripts, styles, SVG, navigation boilerplate
|
||||
let text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<svg[\s\S]*?<\/svg>/gi, " ")
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, " ")
|
||||
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
|
||||
.replace(/<form[\s\S]*?<\/form>/gi, " ")
|
||||
// Block elements → newlines
|
||||
.replace(/<\/?(p|div|section|article|h[1-6]|li|br|hr|tr|td|th|blockquote|pre)[^>]*>/gi, "\n")
|
||||
// Strip all remaining tags
|
||||
.replace(/<[^>]{0,500}>/g, " ")
|
||||
// Decode common entities
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ")
|
||||
.replace(/&[a-z]+;/gi, " ")
|
||||
// Collapse whitespace
|
||||
.split("\n").map(l => l.trim()).filter(l => l.length > 30).join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
// Limit to ~5000 chars — enough for LLM context, not so much it blows the prompt
|
||||
if (text.length > 5000) {
|
||||
text = text.slice(0, 5000) + "\n[… content truncated for LLM context …]";
|
||||
}
|
||||
|
||||
// Detect SPA: very little body text means JS renders the real content
|
||||
const spaDetected = text.length < 300;
|
||||
|
||||
// When SPA detected, enrich text with what we could extract from meta tags
|
||||
if (spaDetected && (metaDesc || ogSiteName)) {
|
||||
const parts: string[] = [];
|
||||
if (ogSiteName) parts.push(`Site: ${ogSiteName}`);
|
||||
if (pageTitle) parts.push(`Title: ${pageTitle}`);
|
||||
if (metaDesc) parts.push(`Description: ${metaDesc}`);
|
||||
text = parts.join("\n");
|
||||
}
|
||||
|
||||
return { pageTitle, text, spaDetected, metaDesc };
|
||||
}
|
||||
|
||||
// POST /api/blog/from-url — Fetch URL, extract content, generate a blog from it
|
||||
blogRouter.post("/from-url", async (req: Request, res: Response) => {
|
||||
const { url, topic } = req.body as { url?: string; topic?: string };
|
||||
|
||||
if (!url) {
|
||||
res.status(400).json({ success: false, error: "url ist erforderlich" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL — must be http/https
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) throw new Error("bad protocol");
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: "Ungültige URL — muss http:// oder https:// beginnen" });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTopic = topic || "technology_deep_dive";
|
||||
const templates = BLOG_TEMPLATES[selectedTopic];
|
||||
if (!templates) {
|
||||
res.status(400).json({ success: false, error: `Ungültiger Blog-Typ. Gültig: ${Object.keys(BLOG_TEMPLATES).join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch page content server-side (no CORS issues)
|
||||
const { pageTitle, text: extractedText, spaDetected, metaDesc } = await fetchUrlContent(url);
|
||||
|
||||
console.log(
|
||||
`Blog from-url: fetched "${pageTitle}" from ${parsedUrl.hostname} ` +
|
||||
`(${extractedText.length} chars${spaDetected ? ", SPA detected" : ""})`
|
||||
);
|
||||
|
||||
// Build a rich additional_context from the URL content.
|
||||
// When a SPA is detected (JS-rendered), body text is a shell — we rely on meta tags instead.
|
||||
const spaWarning = spaDetected
|
||||
? `\nNOTE: This URL is a JavaScript Single Page Application. Only meta/OG data was available ` +
|
||||
`server-side — the LLM should infer topic from the site name, title, and description above. ` +
|
||||
`Do NOT default to optical networking topics unless the page is actually about that.`
|
||||
: "";
|
||||
|
||||
const additionalContext =
|
||||
`SOURCE URL: ${url}\n` +
|
||||
`PAGE TITLE: ${pageTitle}\n` +
|
||||
`HOSTNAME: ${parsedUrl.hostname}\n` +
|
||||
(metaDesc ? `META DESCRIPTION: ${metaDesc}\n` : "") +
|
||||
`\n--- EXTRACTED PAGE CONTENT ---\n` +
|
||||
`${extractedText || "(No body text extractable — JavaScript-rendered SPA)"}\n` +
|
||||
`--- END PAGE CONTENT ---\n` +
|
||||
spaWarning +
|
||||
`\n\nIMPORTANT: Use this content as factual background and editorial direction. ` +
|
||||
`The blog MUST be about the topic described above, NOT about optical transceivers or fiber unless explicitly relevant. ` +
|
||||
`Do NOT copy sentences verbatim. Write a Flexoptix-voice blog article using these facts and insights.`;
|
||||
|
||||
const title = pageTitle || parsedUrl.hostname;
|
||||
const template = templates[Math.floor(Math.random() * templates.length)];
|
||||
|
||||
// When SPA detected, skip optical transceiver product injection — it pollutes the LLM context
|
||||
// with irrelevant product data and causes the model to default to its fine-tuning domain.
|
||||
// Use empty data so the pipeline focuses purely on the URL context provided above.
|
||||
const keywords = spaDetected
|
||||
? [parsedUrl.hostname.replace(/^www\./, ""), pageTitle].filter(Boolean)
|
||||
: [...template.seo_keywords, "optical transceiver", "networking"].filter(Boolean);
|
||||
|
||||
const data = spaDetected
|
||||
? { products: [] as any[], news: [] as any[], faq: [] as any[], troubleshooting: [] as any[] }
|
||||
: await gatherBlogData(keywords, selectedTopic);
|
||||
|
||||
const draftContent = generateTemplateDraft(title, selectedTopic, data);
|
||||
const wordCount = draftContent.split(/\s+/).length;
|
||||
const initialIssues = validateArticle(draftContent);
|
||||
|
||||
const activeModel = getLlmProvider();
|
||||
const generatedBy = `tip-blog-from-url-${activeModel.ollamaModel || activeModel.provider || "llm"}`;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO blog_drafts (title, topic, target_audience, outline, draft_content, data_sources, status, generated_by, word_count, seo_keywords)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, $8, $9)
|
||||
RETURNING id, created_at`,
|
||||
[
|
||||
title,
|
||||
selectedTopic,
|
||||
template.target_audience,
|
||||
JSON.stringify({ generation_method: "from-url", source_url: url, spa_detected: spaDetected, quality_issues: initialIssues }),
|
||||
draftContent,
|
||||
JSON.stringify({ source_url: url, extracted_chars: extractedText.length, spa_detected: spaDetected, products: data.products.length, news: data.news.length }),
|
||||
generatedBy,
|
||||
wordCount,
|
||||
template.seo_keywords,
|
||||
],
|
||||
);
|
||||
|
||||
const draftId = result.rows[0].id;
|
||||
|
||||
// Launch LLM pipeline with URL content as context
|
||||
const health = await checkHealth().catch(() => ({ ok: false, model: "", error: "unreachable" }));
|
||||
let llmStarted = false;
|
||||
if (health.ok) {
|
||||
llmStarted = true;
|
||||
enqueueLlmPipeline(draftId, title, selectedTopic, template.target_audience, data, additionalContext).catch((err) => {
|
||||
console.error(`Blog from-url LLM pipeline error: ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
source_url: url,
|
||||
page_title: pageTitle,
|
||||
extracted_chars: extractedText.length,
|
||||
spa_detected: spaDetected,
|
||||
draft: {
|
||||
id: draftId,
|
||||
title,
|
||||
topic: selectedTopic,
|
||||
target_audience: template.target_audience,
|
||||
word_count: wordCount,
|
||||
generation_method: "from-url",
|
||||
llm_enhancing: llmStarted,
|
||||
created_at: result.rows[0].created_at,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
console.error(`Blog from-url error for ${url}: ${msg}`);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `URL konnte nicht verarbeitet werden: ${msg}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/blog — List all drafts
|
||||
blogRouter.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@ -1557,6 +1790,17 @@ blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => {
|
||||
res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" });
|
||||
});
|
||||
|
||||
// POST /api/blog/llm/refresh-discovery — Force auto-discovery to pick up newly-trained fo-blog-v* versions
|
||||
// Useful right after Magatama adopts a new fo-blog-vN model. Otherwise runs every 10 min by itself.
|
||||
blogRouter.post("/llm/refresh-discovery", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const active = await refreshLlmAutoDiscovery();
|
||||
res.json({ success: true, active, message: `Auto-discovery refreshed. Active: ${active.provider}${active.ollamaModel ? ` (${active.ollamaModel})` : ""}` });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/blog/llm/switch — Switch active LLM provider at runtime (no restart needed)
|
||||
// Body: { provider: "claude-code" | "anthropic" | "ollama", model?: "fo-blog-v10" | "qwen2.5:14b" | ... }
|
||||
blogRouter.post("/llm/switch", (req: Request, res: Response) => {
|
||||
|
||||
@ -1439,6 +1439,40 @@
|
||||
<button onclick="generateBlogManual()" style="background:rgba(99,102,241,0.85);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:0.82rem;font-weight:600">⚙️ Artikel generieren</button>
|
||||
</div><!-- end manual generation -->
|
||||
|
||||
<!-- URL → BLOG PANEL -->
|
||||
<div class="card" style="margin-bottom:1.25rem;border:1px solid rgba(16,185,129,0.35);background:var(--surface2)">
|
||||
<div style="font-size:0.85rem;font-weight:700;color:var(--text-bright);margin-bottom:0.1rem">🔗 Blog aus URL generieren</div>
|
||||
<div style="font-size:0.7rem;color:var(--text-dim);margin-bottom:0.75rem">Link eingeben → Inhalt wird automatisch extrahiert → BlogLLM schreibt einen Artikel daraus</div>
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:0.6rem;margin-bottom:0.65rem;align-items:end">
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">URL</label>
|
||||
<input type="url" id="blog-from-url-input" placeholder="https://example.com/article-about-400g-transceivers"
|
||||
style="width:100%;background:var(--surface);border:1px solid rgba(16,185,129,0.4);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem;box-sizing:border-box"
|
||||
onkeydown="if(event.key==='Enter')generateBlogFromUrl()">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim);display:block;margin-bottom:3px">Blog-Typ</label>
|
||||
<select id="blog-from-url-topic" style="background:var(--surface);border:1px solid rgba(16,185,129,0.4);color:var(--text);padding:6px 10px;border-radius:6px;font-size:0.82rem;height:32px">
|
||||
<option value="technology_deep_dive">Technology Deep Dive</option>
|
||||
<option value="tutorial">Troubleshooting Tutorial</option>
|
||||
<option value="migration_guide">Migration Guide</option>
|
||||
<option value="market_alert">Market Alert</option>
|
||||
<option value="buying_guide">Buying Guide</option>
|
||||
<option value="comparison">Product Comparison</option>
|
||||
<option value="competitor_analysis">Competitor Analysis</option>
|
||||
<option value="hype_cycle">Hype Cycle / Strategy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem">
|
||||
<button onclick="generateBlogFromUrl()" id="blog-from-url-btn"
|
||||
style="background:rgba(16,185,129,0.85);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:0.82rem;font-weight:600">
|
||||
🔗 Aus URL generieren
|
||||
</button>
|
||||
<span id="blog-from-url-status" style="font-size:0.75rem;color:var(--text-dim)"></span>
|
||||
</div>
|
||||
</div><!-- end url→blog panel -->
|
||||
|
||||
<!-- SLL INSIGHTS WIDGET -->
|
||||
<div class="card" style="margin-bottom:1rem;border:1px solid rgba(212,163,115,0.3);background:var(--surface2)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
|
||||
@ -4310,10 +4344,18 @@ async function openSwitchDetail(id) {
|
||||
+ '</div>';
|
||||
fch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.6rem">Passend für diesen Switch — FlexBox-Codierung möglich</div>';
|
||||
|
||||
// Format speed_gbps → "1.6T", "400G", "100G" etc.
|
||||
function fmtSpeed(gbps) {
|
||||
if (!gbps) return '?';
|
||||
var n = parseFloat(gbps);
|
||||
if (n >= 1000) return (n / 1000) + 'T';
|
||||
return Math.round(n) + 'G';
|
||||
}
|
||||
|
||||
// Group by speed class
|
||||
var foGroups = {};
|
||||
foAll.forEach(function(t) {
|
||||
var key = (t.speed_gbps ? t.speed_gbps + 'G' : (t.speed || '?')) + ' ' + (t.form_factor || '?');
|
||||
var key = fmtSpeed(t.speed_gbps) + ' ' + (t.form_factor || '?');
|
||||
if (!foGroups[key]) foGroups[key] = [];
|
||||
foGroups[key].push(t);
|
||||
});
|
||||
@ -4342,10 +4384,27 @@ async function openSwitchDetail(id) {
|
||||
var shopHref = t.product_page_url || ('https://www.flexoptix.net/en/search/ajax/suggest/?q=' + encodeURIComponent(t.part_number || t.standard_name || ''));
|
||||
var reach = t.reach_label ? '<span style="color:var(--text-dim);font-size:0.68rem;margin-left:0.25rem">' + esc(t.reach_label) + '</span>' : '';
|
||||
|
||||
// Stock badges
|
||||
var stockHtml = '';
|
||||
var deQty = parseInt(t.warehouse_de_qty) || 0;
|
||||
var glQty = parseInt(t.warehouse_global_qty) || 0;
|
||||
var boQty = parseInt(t.backorder_qty) || 0;
|
||||
if (deQty > 0 || glQty > 0 || boQty > 0) {
|
||||
stockHtml += '<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.2rem">';
|
||||
if (deQty > 0) stockHtml += '<span style="font-size:0.62rem;background:rgba(16,185,129,0.12);color:#10b981;border:1px solid rgba(16,185,129,0.3);border-radius:3px;padding:1px 5px">DE ' + deQty + ' Stk</span>';
|
||||
if (glQty > 0) stockHtml += '<span style="font-size:0.62rem;background:rgba(59,130,246,0.12);color:#60a5fa;border:1px solid rgba(59,130,246,0.3);border-radius:3px;padding:1px 5px">Global ' + glQty + ' Stk</span>';
|
||||
if (boQty > 0) {
|
||||
var boDate = t.backorder_estimated_date ? ' bis ' + new Date(t.backorder_estimated_date).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit'}) : '';
|
||||
stockHtml += '<span style="font-size:0.62rem;background:rgba(245,158,11,0.12);color:#fbbf24;border:1px solid rgba(245,158,11,0.3);border-radius:3px;padding:1px 5px">Zulauf ' + boQty + boDate + '</span>';
|
||||
}
|
||||
stockHtml += '</div>';
|
||||
}
|
||||
|
||||
fch += '<div style="display:flex;align-items:center;padding:0.35rem 0.5rem;background:rgba(255,102,0,0.05);border:1px solid rgba(255,102,0,0.2);border-radius:6px;gap:0.35rem;cursor:pointer" onclick="openTxDetail(\'' + esc(t.id) + '\')">'
|
||||
+ '<div style="flex:1;min-width:0">'
|
||||
+ '<span style="font-weight:600;font-size:0.8rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
|
||||
+ reach
|
||||
+ '<div><span style="font-weight:600;font-size:0.8rem;color:var(--text-bright)">' + esc(t.part_number || t.standard_name || t.slug) + '</span>'
|
||||
+ reach + '</div>'
|
||||
+ stockHtml
|
||||
+ '</div>'
|
||||
+ (priceStr
|
||||
? '<span style="font-weight:700;font-size:0.78rem;color:#ff6600;white-space:nowrap">' + priceStr + '</span>'
|
||||
@ -5478,6 +5537,55 @@ function generateBlogManual() {
|
||||
}).catch(function(err) { if (err.message !== 'Unauthorized') showToast('Netzwerkfehler', err.message, true); });
|
||||
}
|
||||
|
||||
function generateBlogFromUrl() {
|
||||
var url = (document.getElementById('blog-from-url-input').value || '').trim();
|
||||
var topic = document.getElementById('blog-from-url-topic').value || 'technology_deep_dive';
|
||||
var btn = document.getElementById('blog-from-url-btn');
|
||||
var status = document.getElementById('blog-from-url-status');
|
||||
|
||||
if (!url) { showToast('Fehler', 'Bitte eine URL eingeben', true); return; }
|
||||
try { new URL(url); } catch (e) { showToast('Fehler', 'Ungültige URL', true); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Fetching…';
|
||||
status.textContent = 'Seite wird abgerufen…';
|
||||
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
fetch(API + '/api/blog/from-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
body: JSON.stringify({ url: url, topic: topic })
|
||||
})
|
||||
.then(function(r) { if (r.status === 401) { handleAuthError(401); throw new Error('Unauthorized'); } return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔗 Aus URL generieren';
|
||||
if (data.success) {
|
||||
var spaNote = data.spa_detected
|
||||
? ' ⚠️ SPA erkannt — Inhalt nur via Meta-Tags (JS-gerendert)'
|
||||
: ' ✓ ' + data.extracted_chars + ' Zeichen extrahiert';
|
||||
status.textContent = spaNote + ' — Pipeline läuft (~10 min)';
|
||||
if (data.spa_detected) {
|
||||
showToast('⚠️ SPA erkannt', (data.page_title || url) + ' — JavaScript-Seite, Inhalt via Meta-Tags. Pipeline läuft.');
|
||||
} else {
|
||||
showToast('✓ Pipeline gestartet', (data.page_title || url) + ' — wird in ~10 min fertig');
|
||||
}
|
||||
document.getElementById('blog-from-url-input').value = '';
|
||||
loadBlogDrafts();
|
||||
pollBlogLlm(data.draft.id, 0);
|
||||
} else {
|
||||
status.textContent = '✗ Fehler: ' + (data.error || 'Unbekannt');
|
||||
showToast('Fehler', data.error || 'Unbekannter Fehler', true);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔗 Aus URL generieren';
|
||||
status.textContent = '';
|
||||
if (err.message !== 'Unauthorized') showToast('Netzwerkfehler', err.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
function pollBlogLlm(id, attempt) {
|
||||
if (attempt > 60) return; // max 10 min (60 × 10s)
|
||||
setTimeout(function() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user