feat: 4th verification criterion (Competitor) + scraper frequency FS/10Gtek/ProLabs to 2h
This commit is contained in:
parent
e496a91dd5
commit
6d865cabb9
@ -1208,6 +1208,55 @@
|
||||
<div class="loading pulse">Loading topics...</div>
|
||||
</div>
|
||||
<div id="blog-pipeline-status"></div>
|
||||
|
||||
<!-- 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">
|
||||
<div>
|
||||
<span style="font-size:0.85rem;font-weight:600;color:var(--accent2)">🧠 Self-Learning Loop (SLL v1.0)</span>
|
||||
<span id="sll-status-badge" style="margin-left:0.5rem;font-size:0.7rem;padding:2px 7px;border-radius:10px;background:rgba(100,100,100,0.3);color:var(--text-dim)">loading…</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button onclick="sllAnalyze()" id="sll-analyze-btn" style="background:rgba(212,163,115,0.2);color:var(--accent2);border:1px solid rgba(212,163,115,0.4);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:0.75rem">⚡ Analyze Patterns</button>
|
||||
<button onclick="loadSLLInsights()" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sll-insights-content" style="font-size:0.8rem;color:var(--text-dim)">Loading SLL data…</div>
|
||||
|
||||
<!-- Log Performance Modal Trigger -->
|
||||
<div style="margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border)">
|
||||
<span style="font-size:0.75rem;color:var(--text-dim)">Log LinkedIn engagement for a post:</span>
|
||||
<button onclick="showSLLPerformanceForm()" style="margin-left:0.5rem;background:rgba(212,163,115,0.15);color:var(--accent2);border:1px solid rgba(212,163,115,0.3);padding:3px 10px;border-radius:6px;cursor:pointer;font-size:0.75rem">+ Log Engagement</button>
|
||||
</div>
|
||||
<div id="sll-perf-form" style="display:none;margin-top:0.75rem">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0.5rem;margin-bottom:0.5rem">
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim)">Comments</label>
|
||||
<input type="number" id="sll-comments" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim)">Shares</label>
|
||||
<input type="number" id="sll-shares" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim)">Saves</label>
|
||||
<input type="number" id="sll-saves" min="0" value="0" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.7rem;color:var(--text-dim)">Impressions</label>
|
||||
<input type="number" id="sll-impressions" min="0" placeholder="optional" style="width:100%;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.85rem">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||||
<select id="sll-blog-select" style="flex:1;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:0.8rem">
|
||||
<option value="">Select blog post…</option>
|
||||
</select>
|
||||
<button onclick="submitSLLPerformance()" style="background:var(--accent);color:white;border:none;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:0.8rem">Save</button>
|
||||
<button onclick="document.getElementById('sll-perf-form').style.display='none'" style="background:transparent;color:var(--text-dim);border:1px solid var(--border);padding:5px 10px;border-radius:6px;cursor:pointer;font-size:0.8rem">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
|
||||
</div>
|
||||
|
||||
@ -1804,7 +1853,7 @@ function goToTab(tabName) {
|
||||
if (tabName === 'news') loadNews(1);
|
||||
if (tabName === 'vendors') loadVendors();
|
||||
if (tabName === 'standards') loadStandardsList();
|
||||
if (tabName === 'blog') loadBlogDrafts();
|
||||
if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); }
|
||||
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
|
||||
if (tabName === 'crawlers') loadCrawlerStatus();
|
||||
if (tabName === 'procurement') loadProcurement();
|
||||
@ -2669,16 +2718,26 @@ async function openTxDetail(id) {
|
||||
var iVer = t.image_verified === true;
|
||||
var dVer = t.details_verified === true;
|
||||
var fVer = t.fully_verified === true;
|
||||
// Competitor verified: at least 1 price from a non-Flexoptix vendor in last 30 days
|
||||
var allPricesForBadge = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; });
|
||||
var cVer = allPricesForBadge.some(function(p) {
|
||||
return p.vendor_name && p.vendor_name.toUpperCase().indexOf('FLEXOPTIX') === -1;
|
||||
});
|
||||
var verItems = [];
|
||||
if (pVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Price</span>');
|
||||
if (iVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Image</span>');
|
||||
if (dVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Details</span>');
|
||||
if (fVer) {
|
||||
if (cVer) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem;font-weight:600">✓ Competitor</span>');
|
||||
else verItems.push('<span style="color:#b45309;font-size:0.75rem;font-weight:500" title="Competitor prices are being researched 24/7">⟳ Competitor</span>');
|
||||
// 100% VERIFIED requires all 4: Price + Image + Details + Competitor
|
||||
var fullyVerified = fVer && cVer;
|
||||
if (fullyVerified) {
|
||||
// Inside the green bar: all text must be white/light — not #2d6a4f (same as bg)
|
||||
var fvItems = [];
|
||||
if (pVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Price</span>');
|
||||
if (iVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Image</span>');
|
||||
if (dVer) fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Details</span>');
|
||||
fvItems.push('<span style="color:rgba(255,255,255,0.92);font-size:0.75rem;font-weight:600">✓ Competitor</span>');
|
||||
h += '<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;margin:0.8rem 0;padding:0.55rem 0.85rem;background:linear-gradient(135deg,#1b4332,#2d6a4f);border-radius:8px">'
|
||||
+ '<span style="color:#fff;font-size:0.82rem;font-weight:700;letter-spacing:0.04em">★ 100% VERIFIED</span>'
|
||||
+ '<span style="color:rgba(255,255,255,0.4);font-size:0.7rem">–</span>'
|
||||
@ -4003,7 +4062,7 @@ function pollBlogLlm(id, attempt) {
|
||||
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
|
||||
if (badge) {
|
||||
badge.className = 'b b-green blog-status-badge';
|
||||
badge.textContent = 'ready';
|
||||
badge.textContent = 'ready ✓';
|
||||
}
|
||||
api('/api/blog/' + id).then(function(data) {
|
||||
if (data.draft) {
|
||||
@ -4016,7 +4075,7 @@ function pollBlogLlm(id, attempt) {
|
||||
var badge = document.querySelector('.ri[data-blog-id="' + id + '"] .blog-status-badge');
|
||||
if (badge) {
|
||||
badge.className = 'b b-yellow blog-status-badge';
|
||||
badge.textContent = 'step ' + p.step + '/10';
|
||||
badge.textContent = 'step ' + p.step + '/14';
|
||||
}
|
||||
pollBlogLlm(id, attempt + 1);
|
||||
}
|
||||
@ -4206,7 +4265,7 @@ async function loadBlogDrafts() {
|
||||
buildDOM(el('blog-list'), drafts.map(function(d) {
|
||||
var p = progressMap[d.id] || {};
|
||||
var isRunning = p.running === true;
|
||||
// Status badge: generating → step X/10, done → ready, published → published, review → review
|
||||
// Status badge: generating → step X/N, review/fo-blog done → ready ✓, approved → approved, published → published, draft → draft
|
||||
var statusLabel, statusClass;
|
||||
if (isRunning) {
|
||||
statusLabel = p.step ? 'step ' + p.step + '/' + (p.total || 14) : 'generating…';
|
||||
@ -4214,10 +4273,10 @@ async function loadBlogDrafts() {
|
||||
} else if (d.status === 'published') {
|
||||
statusLabel = 'published';
|
||||
statusClass = 'b-green';
|
||||
} else if (d.status === 'review') {
|
||||
statusLabel = 'review';
|
||||
statusClass = 'b-yellow';
|
||||
} else if (d.pipeline_steps_completed >= 10 || (d.generated_by || '').includes('fo-blog')) {
|
||||
} else if (d.status === 'approved') {
|
||||
statusLabel = 'approved ✓';
|
||||
statusClass = 'b-green';
|
||||
} else if (d.status === 'review' || d.pipeline_steps_completed >= 10 || (d.generated_by || '').includes('fo-blog')) {
|
||||
statusLabel = 'ready ✓';
|
||||
statusClass = 'b-green';
|
||||
} else {
|
||||
@ -4335,9 +4394,10 @@ async function updateBlogStatus(id, status) {
|
||||
async function regenerateBlog(id) {
|
||||
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
|
||||
try {
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
var data = await fetch(API + '/api/blog/' + id + '/regenerate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
|
||||
}).then(function(r) { return r.json(); });
|
||||
if (data.success) {
|
||||
showToast('Gestartet', 'LLM läuft – Status wird aktualisiert');
|
||||
@ -4350,6 +4410,153 @@ async function regenerateBlog(id) {
|
||||
} catch(e) { showToast('Error', e.message, true); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SLL v1.0 — Self-Learning Loop
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadSLLInsights() {
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
var el = document.getElementById('sll-insights-content');
|
||||
var badge = document.getElementById('sll-status-badge');
|
||||
if (!el) return;
|
||||
try {
|
||||
var r = await fetch(API + '/api/blog/sll/insights', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) { el.innerHTML = '<span style="color:#c1121f">Error loading SLL data</span>'; return; }
|
||||
|
||||
var stats = d.stats;
|
||||
var ready = d.sll_ready;
|
||||
badge.textContent = ready ? 'Active — ' + stats.total_posts + ' posts' : 'Needs data — ' + stats.total_posts + '/5 posts';
|
||||
badge.style.background = ready ? 'rgba(34,197,94,0.2)' : 'rgba(234,179,8,0.2)';
|
||||
badge.style.color = ready ? '#4ade80' : '#fbbf24';
|
||||
|
||||
var h = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-bottom:0.75rem">';
|
||||
h += '<div style="text-align:center;padding:6px;background:rgba(212,163,115,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:var(--accent2)">' + (stats.best_score || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">Best Score</div></div>';
|
||||
h += '<div style="text-align:center;padding:6px;background:rgba(255,215,0,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:#ffd700">' + (stats.tiers.gold || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">🥇 Gold</div></div>';
|
||||
h += '<div style="text-align:center;padding:6px;background:rgba(192,192,192,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:#c0c0c0">' + (stats.tiers.silver || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">🥈 Silver</div></div>';
|
||||
h += '<div style="text-align:center;padding:6px;background:rgba(100,100,100,0.1);border-radius:6px"><div style="font-size:1.1rem;font-weight:700;color:var(--text-dim)">' + (stats.tiers.miss || 0) + '</div><div style="font-size:0.65rem;color:var(--text-dim)">Miss</div></div>';
|
||||
h += '</div>';
|
||||
|
||||
var winners = d.learned_patterns.winners || [];
|
||||
var losers = d.learned_patterns.losers || [];
|
||||
|
||||
if (winners.length > 0) {
|
||||
h += '<div style="margin-bottom:0.4rem"><span style="color:#4ade80;font-size:0.75rem;font-weight:600">✔ WHAT WORKS</span></div>';
|
||||
h += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.6rem">';
|
||||
winners.forEach(function(p) {
|
||||
h += '<span style="background:rgba(34,197,94,0.12);border:1px solid rgba(34,197,94,0.3);color:#4ade80;padding:2px 8px;border-radius:10px;font-size:0.7rem">[' + p.pattern_type + '] ' + p.pattern_value + '</span>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (losers.length > 0) {
|
||||
h += '<div style="margin-bottom:0.4rem"><span style="color:#f87171;font-size:0.75rem;font-weight:600">✗ WHAT FAILS</span></div>';
|
||||
h += '<div style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-bottom:0.6rem">';
|
||||
losers.forEach(function(p) {
|
||||
h += '<span style="background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.25);color:#f87171;padding:2px 8px;border-radius:10px;font-size:0.7rem">[' + p.pattern_type + '] ' + p.pattern_value + '</span>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (winners.length === 0 && losers.length === 0) {
|
||||
h += '<div style="color:var(--text-dim);font-size:0.75rem">' + d.note + '</div>';
|
||||
}
|
||||
|
||||
if (d.top_posts && d.top_posts.length > 0) {
|
||||
h += '<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">TOP PERFORMERS: ';
|
||||
h += d.top_posts.slice(0,3).map(function(p) { return '<span style="color:var(--accent2)">' + (p.title || '?').slice(0,40) + ' (' + p.engagement_score + ')</span>'; }).join(' · ');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = h;
|
||||
|
||||
// Also populate blog select for performance form
|
||||
populateSLLBlogSelect();
|
||||
} catch(e) {
|
||||
el.innerHTML = '<span style="color:var(--text-dim);font-size:0.75rem">SLL data unavailable</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function populateSLLBlogSelect() {
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
var sel = document.getElementById('sll-blog-select');
|
||||
if (!sel) return;
|
||||
try {
|
||||
var r = await fetch(API + '/api/blog?limit=50', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
var drafts = (d.drafts || d.data || []);
|
||||
sel.innerHTML = '<option value="">Select blog post…</option>';
|
||||
drafts.forEach(function(b) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = b.id;
|
||||
opt.textContent = (b.title || 'Untitled').slice(0, 60) + ' (' + (b.status || '?') + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function showSLLPerformanceForm() {
|
||||
var form = document.getElementById('sll-perf-form');
|
||||
if (!form) return;
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
if (form.style.display === 'block') populateSLLBlogSelect();
|
||||
}
|
||||
|
||||
async function submitSLLPerformance() {
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
var blogId = document.getElementById('sll-blog-select').value;
|
||||
if (!blogId) { showToast('Fehler', 'Bitte Blog-Post auswählen', true); return; }
|
||||
var comments = parseInt(document.getElementById('sll-comments').value) || 0;
|
||||
var shares = parseInt(document.getElementById('sll-shares').value) || 0;
|
||||
var saves = parseInt(document.getElementById('sll-saves').value) || 0;
|
||||
var impressions = parseInt(document.getElementById('sll-impressions').value) || null;
|
||||
|
||||
try {
|
||||
var r = await fetch(API + '/api/blog/' + blogId + '/performance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||
body: JSON.stringify({ comments: comments, shares: shares, saves: saves, impressions: impressions })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
var tier = d.tier;
|
||||
var tierEmoji = tier === 'gold' ? '🥇' : tier === 'silver' ? '🥈' : tier === 'bronze' ? '🥉' : '📉';
|
||||
showToast('Gespeichert ' + tierEmoji, 'Score: ' + d.engagement_score + ' (' + tier + ')');
|
||||
document.getElementById('sll-perf-form').style.display = 'none';
|
||||
document.getElementById('sll-comments').value = '0';
|
||||
document.getElementById('sll-shares').value = '0';
|
||||
document.getElementById('sll-saves').value = '0';
|
||||
document.getElementById('sll-impressions').value = '';
|
||||
loadSLLInsights();
|
||||
} else {
|
||||
showToast('Fehler', d.error || 'Unbekannter Fehler', true);
|
||||
}
|
||||
} catch(e) { showToast('Error', e.message, true); }
|
||||
}
|
||||
|
||||
async function sllAnalyze() {
|
||||
var token = window.loadToken ? window.loadToken() : '';
|
||||
var btn = document.getElementById('sll-analyze-btn');
|
||||
if (btn) { btn.textContent = '⏳ Analyzing…'; btn.disabled = true; }
|
||||
try {
|
||||
var r = await fetch(API + '/api/blog/sll/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
showToast('SLL Analysis Complete', (d.posts_analyzed || 0) + ' posts · ' + (d.patterns_saved || 0) + ' patterns · "' + (d.key_insight || '') + '"');
|
||||
loadSLLInsights();
|
||||
} else {
|
||||
showToast('Fehler', d.error || 'Analysis failed', true);
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Error', e.message, true);
|
||||
} finally {
|
||||
if (btn) { btn.textContent = '⚡ Analyze Patterns'; btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// TABLE SORTING
|
||||
function makeSortable(table) {
|
||||
if (!table) return;
|
||||
|
||||
@ -52,8 +52,6 @@ export async function createScheduler(): Promise<PgBoss> {
|
||||
retryBackoff: true,
|
||||
expireInSeconds: 300,
|
||||
monitorStateIntervalSeconds: 60,
|
||||
max: 4, // pg-boss internal connection pool
|
||||
poolSize: 4, // alias used by some pg-boss versions
|
||||
});
|
||||
|
||||
boss.on("error", (error) => console.error("pg-boss error:", error));
|
||||
@ -137,20 +135,20 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// PLAYWRIGHT SCRAPERS — every 8h (resource-heavy, runs on Erik VPS)
|
||||
// PLAYWRIGHT SCRAPERS — priority competitors every 2h, others every 4h
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// FS.com: 01:00, 09:00, 17:00
|
||||
await boss.schedule("scrape:pricing:fs", "0 1,9,17 * * *", {}, { retryLimit: 3, expireInSeconds: 5400 });
|
||||
// FS.com: every 2h — primary competitor, highest data value
|
||||
await boss.schedule("scrape:pricing:fs", "0 */2 * * *", {}, { retryLimit: 3, expireInSeconds: 5400 });
|
||||
|
||||
// 10Gtek: 01:20, 09:20, 17:20
|
||||
await boss.schedule("scrape:pricing:10gtek", "20 1,9,17 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
// 10Gtek: every 2h offset by 20min
|
||||
await boss.schedule("scrape:pricing:10gtek", "20 */2 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
|
||||
// ATGBICS: 01:50, 09:50, 17:50
|
||||
await boss.schedule("scrape:pricing:atgbics", "50 1,9,17 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
// ATGBICS: every 4h (staggered)
|
||||
await boss.schedule("scrape:pricing:atgbics", "50 0,4,8,12,16,20 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
|
||||
// ProLabs: 02:20, 10:20, 18:20
|
||||
await boss.schedule("scrape:pricing:prolabs", "20 2,10,18 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
// ProLabs: every 2h offset by 40min
|
||||
await boss.schedule("scrape:pricing:prolabs", "40 */2 * * *", {}, { retryLimit: 2, expireInSeconds: 3600 });
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// FETCH/CHEERIO SCRAPERS — every 4h (lightweight, Pi-friendly)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user