feat: implement content generator for steel sector editorial generation
- Added content-generator.js to handle the generation of "At a Glance" and "Radar Future" articles. - Integrated OpenAI API for generating editorial content based on provided news and sources. - Implemented job management for tracking the status of content generation tasks. - Added functions for fetching news, processing source materials, and creating drafts for articles. - Established structured prompts for AI to ensure content adheres to specified formats and requirements.
This commit is contained in:
parent
9b5c1b5719
commit
03405e3f5d
|
|
@ -0,0 +1,40 @@
|
|||
const { run: runStats } = require("./caveman-stats");
|
||||
|
||||
function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
});
|
||||
}
|
||||
|
||||
function respond(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const raw = await readStdin();
|
||||
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||
const prompt = String(input.prompt || input.userPrompt || "").trim();
|
||||
|
||||
if (/^\/caveman-stats(?:\s|$)/i.test(prompt)) {
|
||||
respond({
|
||||
decision: "block",
|
||||
reason: runStats(input),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
respond({});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
respond({
|
||||
decision: "block",
|
||||
reason: `caveman hook failed: ${error.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const DEFAULT_BASELINE_MULTIPLIER = 2.9;
|
||||
|
||||
function readJsonl(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
|
||||
return fs
|
||||
.readFileSync(filePath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectUsage(entry) {
|
||||
const candidates = [
|
||||
entry.usage,
|
||||
entry.message && entry.message.usage,
|
||||
entry.response && entry.response.usage,
|
||||
entry.result && entry.result.usage,
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates.reduce(
|
||||
(sum, usage) => {
|
||||
sum.input += Number(usage.input_tokens || usage.inputTokens || usage.prompt_tokens || 0);
|
||||
sum.output += Number(usage.output_tokens || usage.outputTokens || usage.completion_tokens || 0);
|
||||
return sum;
|
||||
},
|
||||
{ input: 0, output: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function compactNumber(value) {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}m`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Math.round(value).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function buildStats(input) {
|
||||
const transcriptPath = input.transcript_path || input.transcriptPath;
|
||||
const entries = readJsonl(transcriptPath);
|
||||
const usage = entries.reduce(
|
||||
(sum, entry) => {
|
||||
const item = collectUsage(entry);
|
||||
sum.input += item.input;
|
||||
sum.output += item.output;
|
||||
return sum;
|
||||
},
|
||||
{ input: 0, output: 0 },
|
||||
);
|
||||
|
||||
const turns = entries.filter((entry) => entry.type === "assistant" || entry.type === "user").length;
|
||||
const baselineOutput = Math.round(usage.output * DEFAULT_BASELINE_MULTIPLIER);
|
||||
const saved = Math.max(0, baselineOutput - usage.output);
|
||||
const percent = baselineOutput ? Math.round((saved / baselineOutput) * 100) : 0;
|
||||
|
||||
return {
|
||||
turns,
|
||||
input: usage.input,
|
||||
output: usage.output,
|
||||
baselineOutput,
|
||||
saved,
|
||||
percent,
|
||||
transcriptPath,
|
||||
};
|
||||
}
|
||||
|
||||
function writeLifetimeSuffix(input, stats) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const outPath = path.join(cwd, ".agents", "hooks", ".caveman-lifetime-savings");
|
||||
|
||||
try {
|
||||
fs.writeFileSync(outPath, ` ${compactNumber(stats.saved)}`, "utf8");
|
||||
} catch {
|
||||
// Stats display should still work if this optional badge write fails.
|
||||
}
|
||||
}
|
||||
|
||||
function formatStats(stats) {
|
||||
if (!stats.transcriptPath) {
|
||||
return "No transcript path in hook input. Cannot compute real token stats.";
|
||||
}
|
||||
|
||||
if (!stats.input && !stats.output) {
|
||||
return [
|
||||
"No token usage found in current transcript.",
|
||||
`Transcript: ${stats.transcriptPath}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`Session: ${formatNumber(stats.turns)} turns`,
|
||||
`Input: ${formatNumber(stats.input)} tokens`,
|
||||
`Output: ${formatNumber(stats.output)} tokens (caveman)`,
|
||||
`Baseline:${formatNumber(stats.baselineOutput).padStart(10)} tokens (estimated without caveman)`,
|
||||
`Saved: ${formatNumber(stats.saved).padStart(10)} tokens (~${stats.percent}%)`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function run(input) {
|
||||
const stats = buildStats(input || {});
|
||||
writeLifetimeSuffix(input || {}, stats);
|
||||
return formatStats(stats);
|
||||
}
|
||||
|
||||
module.exports = { run, buildStats, formatStats };
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -2,6 +2,18 @@
|
|||
"env": {
|
||||
"ECC_DISABLED_HOOKS": "pre:edit-write:gateguard-fact-force,pre:bash:gateguard-fact-force"
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node .agents/hooks/caveman-mode-tracker.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker --version)",
|
||||
|
|
@ -73,9 +85,6 @@
|
|||
"Bash(python assets/scripts/search.py \"steel industry admin dashboard CMS analytics RTL\" --design-system -p \"Foolad Panel\")",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\DATIS STAR\\\\پنل\\\\src\\\\app\\\\api\\\\\" -Recurse -File)",
|
||||
"Read(//c/Users/DATIS STAR/**)",
|
||||
<<<<<<< HEAD
|
||||
"Bash(git branch *)"
|
||||
=======
|
||||
"Bash(git branch *)",
|
||||
"PowerShell(\\(Get-Item \"c:\\\\Users\\\\DATIS STAR\\\\andishkade-foolad\\\\public\\\\logo.png\"\\).Length)",
|
||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\DATIS STAR\\\\andishkade-foolad\" -Force)",
|
||||
|
|
@ -90,7 +99,6 @@
|
|||
"Bash(huggingface-cli version *)",
|
||||
"Bash(hf auth *)",
|
||||
"Bash(hf upload *)"
|
||||
>>>>>>> space/main
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\DATIS STAR\\پنل"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ The panel uses Postgres (`pg` Pool over `DATABASE_URL`) behind a better-sqlite3-
|
|||
- **Globe** (`src/components/ui/globe.tsx`): three-globe mounted in react-three-fiber via `<primitive>` (NOT via `extend`/JSX-element augmentation — that augmentation poisons global JSX types and breaks unrelated components like `React.ElementType` icons). Country polygons load at runtime from `public/globe-countries.geojson` (bundled, public-domain Natural Earth).
|
||||
- **Calendar** (`src/components/ui/EventCalendar.tsx`): a native Jalali (Persian) month grid built with `Intl.DateTimeFormat('en-US-u-ca-persian', …)` — **no date library**. It walks Gregorian `Date`s and reads their Persian parts to lay out the grid.
|
||||
- **Content layer**: `src/content/*.ts` and inline arrays hold all section data. These are mock/seed data and are explicitly intended to be replaced by `fetch` calls to the panel API later (see "Wiring", below). Keep new editorial data in this shape so the swap stays mechanical.
|
||||
- **Generator shape**: editorial automation must treat `در یک نگاه` and `رادار آینده` as separate products. `در یک نگاه` is short card copy. `رادار آینده` is long-form analysis with multiple inputs, in-text citations, intro/build-up/conclusion, and article blocks that map to `/radar/post/:id`.
|
||||
|
||||
## Admin panel architecture (`panel/`)
|
||||
|
||||
|
|
@ -194,4 +195,4 @@ rtk init --global # Add RTK to ~/.claude/CLAUDE.md
|
|||
| Network | curl, wget | 65-70% |
|
||||
|
||||
Overall average: **60-90% token reduction** on common development operations.
|
||||
<!-- /rtk-instructions -->
|
||||
<!-- /rtk-instructions -->
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function Sources({ sources, lang }: { sources: { title: string; url: string }[];
|
|||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sources.map((s, i) => (
|
||||
<li key={i} style={{ fontSize: 13, color: NAVY, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: GOLD, flexShrink: 0 }}>•</span>
|
||||
<span style={{ color: GOLD, flexShrink: 0, fontWeight: 900, direction: 'ltr' }}>[{i + 1}]</span>
|
||||
{s.url ? (
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer" style={{ color: NAVY, fontWeight: 700, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 5, direction: 'ltr' }}>
|
||||
{s.title || s.url}<ExternalLink size={12} color={GOLD} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
|||
C:\Python314\python.exe
|
||||
|
|
@ -0,0 +1 @@
|
|||
C:\Users\DATIS STAR\andishkade-foolad
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
# Graph Report - . (2026-07-04)
|
||||
|
||||
## Corpus Check
|
||||
- Large corpus: 273 files · ~1,376,288 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder.
|
||||
|
||||
## Summary
|
||||
- 1188 nodes · 1665 edges · 75 communities (65 shown, 10 thin omitted)
|
||||
- Extraction: 96% EXTRACTED · 4% INFERRED · 0% AMBIGUOUS · INFERRED: 74 edges (avg confidence: 0.75)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
- [[_COMMUNITY_Community 27|Community 27]]
|
||||
- [[_COMMUNITY_Community 28|Community 28]]
|
||||
- [[_COMMUNITY_Community 29|Community 29]]
|
||||
- [[_COMMUNITY_Community 30|Community 30]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
- [[_COMMUNITY_Community 50|Community 50]]
|
||||
- [[_COMMUNITY_Community 51|Community 51]]
|
||||
- [[_COMMUNITY_Community 52|Community 52]]
|
||||
- [[_COMMUNITY_Community 53|Community 53]]
|
||||
- [[_COMMUNITY_Community 54|Community 54]]
|
||||
- [[_COMMUNITY_Community 55|Community 55]]
|
||||
- [[_COMMUNITY_Community 56|Community 56]]
|
||||
- [[_COMMUNITY_Community 57|Community 57]]
|
||||
- [[_COMMUNITY_Community 58|Community 58]]
|
||||
- [[_COMMUNITY_Community 59|Community 59]]
|
||||
- [[_COMMUNITY_Community 60|Community 60]]
|
||||
- [[_COMMUNITY_Community 61|Community 61]]
|
||||
- [[_COMMUNITY_Community 62|Community 62]]
|
||||
- [[_COMMUNITY_Community 63|Community 63]]
|
||||
- [[_COMMUNITY_Community 64|Community 64]]
|
||||
- [[_COMMUNITY_Community 65|Community 65]]
|
||||
- [[_COMMUNITY_Community 66|Community 66]]
|
||||
- [[_COMMUNITY_Community 67|Community 67]]
|
||||
- [[_COMMUNITY_Community 68|Community 68]]
|
||||
- [[_COMMUNITY_Community 69|Community 69]]
|
||||
- [[_COMMUNITY_Community 70|Community 70]]
|
||||
- [[_COMMUNITY_Community 71|Community 71]]
|
||||
- [[_COMMUNITY_Community 72|Community 72]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `$()` - 42 edges
|
||||
2. `useLang()` - 32 edges
|
||||
3. `escapeHtml()` - 26 edges
|
||||
4. `api()` - 23 edges
|
||||
5. `topbarHtml()` - 22 edges
|
||||
6. `wireTabs()` - 22 edges
|
||||
7. `compilerOptions` - 18 edges
|
||||
8. `escapeAttr()` - 17 edges
|
||||
9. `compilerOptions` - 16 edges
|
||||
10. `validate()` - 14 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `ActivityTracker()` --calls--> `useAuth()` [INFERRED]
|
||||
frontend/src/app/layout/RootLayout.tsx → frontend/src/context/AuthContext.tsx
|
||||
- `DigitLocalizer()` --calls--> `useLang()` [INFERRED]
|
||||
frontend/src/app/layout/RootLayout.tsx → frontend/src/context/LangContext.tsx
|
||||
- `AnimatedRadialChart()` --calls--> `cn()` [INFERRED]
|
||||
frontend/src/components/ui/animated-radial-chart.tsx → frontend/src/lib/utils.ts
|
||||
- `BorderBeam()` --calls--> `cn()` [INFERRED]
|
||||
frontend/src/components/ui/border-beam.tsx → frontend/src/lib/utils.ts
|
||||
- `Vo2MaxCard()` --calls--> `cn()` [INFERRED]
|
||||
frontend/src/components/ui/progress.tsx → frontend/src/lib/utils.ts
|
||||
|
||||
## Import Cycles
|
||||
- 1-file cycle: `frontend/src/pages/Radar/Radar.tsx -> frontend/src/pages/Radar/Radar.tsx`
|
||||
|
||||
## Communities (75 total, 10 thin omitted)
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.09
|
||||
Nodes (65): $(), api(), CATEGORIES, CHART_SERIES, CONTENT_TYPES, escapeAttr(), escapeHtml(), EVENT_TYPES (+57 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.04
|
||||
Nodes (27): ADMIN_IPS, ALLOWED_LEVELS, ALLOWED_ORIGINS, ALLOWED_SERIES, app, BANNER_POSITIONS, contactLimiter, cookieSecure() (+19 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.04
|
||||
Nodes (47): dependencies, apexcharts, class-variance-authority, clsx, framer-motion, gsap, leaflet, lenis (+39 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.05
|
||||
Nodes (24): CONTACT, Footer(), IconProps, NAV_LINKS, SOCIALS, ABOUT, MEDIA, Section (+16 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.06
|
||||
Nodes (28): ChangeCell(), chipStyle(), COMPANY_DOMAIN, Competitor, competitors, CompetitorsModule(), CompSortKey, faDigits() (+20 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.06
|
||||
Nodes (32): BudgetLine, cardBase(), catBg, catBorderColor, EASE, EXP, faNum(), FEED_CATEGORIES (+24 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.07
|
||||
Nodes (24): bootstrapRadar(), RADAR_CATEGORIES, RADAR_CATEGORY_LABEL, RADAR_ITEMS, RadarCategory, RadarItem, asCats(), bootstrapRadarPages() (+16 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.06
|
||||
Nodes (23): bootstrapMarket(), ExportData, GlobalComparison, InstituteStats, MarketPrice, marketPrices, monthlyExport, priceHistory (+15 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.12
|
||||
Nodes (27): main(), print_usage(), backup_dir_for(), build_compress_prompt(), build_fix_prompt(), call_claude(), compress_file(), is_sensitive_path() (+19 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.08
|
||||
Nodes (18): Banner, bannerFor(), banners, bootstrapBanners(), AtAGlance(), Card, CardModal(), CARDS (+10 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): dependencies, @aws-sdk/client-s3, bcryptjs, cheerio, cookie-parser, cors, dotenv, express (+21 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.16
|
||||
Nodes (22): benchmark_pair(), count_tokens(), main(), print_table(), Path, count_bullets(), extract_code_blocks(), extract_headings() (+14 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.08
|
||||
Nodes (15): FilterTab, GeoRow, geoRows, megaTrends, publications, SignalDir, startups, TECH_CAT (+7 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.09
|
||||
Nodes (13): Home(), Cover, COVERS, EASE, FactoryReportsScroll(), FALLBACK_DIRS, ScrollDir, EASE (+5 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.10
|
||||
Nodes (11): IRAN_SITES, IranSite, SITE_TYPE_LABEL, SiteType, buildTree(), NEWS, NewsItem, norm() (+3 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.10
|
||||
Nodes (20): compilerOptions, allowImportingTsExtensions, erasableSyntaxOnly, jsx, lib, module, moduleDetection, moduleResolution (+12 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.11
|
||||
Nodes (16): CounterContext, CounterContextType, defaultData, FinancialScoreButtonProps, FinancialScoreCard(), FinancialScoreCardProps, FinancialScoreDisplay(), FinancialScoreDisplayProps (+8 more)
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.12
|
||||
Nodes (13): db, pool, events, stmt, reports, stmt, CAT_TO_SUBMENU, GLANCE (+5 more)
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.11
|
||||
Nodes (6): KW_EN, KW_FA, KW_POS, LoginForm(), Mode, RegisterForm()
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.11
|
||||
Nodes (10): BASE_METALS, UPDATE_LOG, DATA_SOURCES, PRICE_TABLE_ROWS, PRODUCT_CATEGORIES, Pulse(), SLIDES, STATS (+2 more)
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.18
|
||||
Nodes (15): COMPANIES, daemon(), __dirname, faDate(), fetchItems(), fetchT(), HISTORY, loadHistory() (+7 more)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): card, ContactCard(), EMPTY, Extra, IdentityCard(), keyFor(), loadExtra(), saveExtra() (+1 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): GreenSteelProject, greenSteelProjects, COUNTRY_FA, faNum(), GreenSteelTracker(), REGION_COLOR, REGION_FA, REGION_ORDER (+5 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.11
|
||||
Nodes (17): compilerOptions, allowImportingTsExtensions, erasableSyntaxOnly, lib, module, moduleDetection, moduleResolution, noEmit (+9 more)
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 0.11
|
||||
Nodes (16): rowToAdvisoryMember(), rowToArticle(), rowToBanner(), rowToEvent(), rowToFactoryReport(), rowToInstituteStat(), rowToIntegration(), rowToMarketChartPoint() (+8 more)
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 0.18
|
||||
Nodes (14): CATEGORIES, createDraft(), decode(), generate(), get(), main(), NEWS_SITES, PANEL (+6 more)
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 0.12
|
||||
Nodes (8): Arc, ARCS, COLORS, Globe(), MapEventsSection(), EASE, MembershipGate(), MembershipGate()
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): main(), readStdin(), respond(), { run: runStats }, buildStats(), compactNumber(), formatNumber(), formatStats() (+5 more)
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 0.12
|
||||
Nodes (11): About(), COUNCIL_MEMBERS, COUNCIL_PILLARS, DEPT_TEAMS, DEPTS, DIRECTOR, DIRECTOR_PILLARS, memberGrid (+3 more)
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 0.18
|
||||
Nodes (13): decode(), __dirname, get(), main(), MINE_SRC, norm(), parseRss(), ROOT (+5 more)
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 0.13
|
||||
Nodes (10): Lang, LangContext, LangCtx, InternalNewsSection(), NEWS, NewsItem, T, Membership() (+2 more)
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 0.15
|
||||
Nodes (12): COUNTRY_FA, faDigits(), faNum(), linkBtn, METHOD_COLOR, METHOD_FA, METHOD_ORDER, Plant (+4 more)
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): ADVISORY_BOARD, BANNERS, EVENTS, FACTORY_REPORTS, INTEGRATIONS, main(), PLANS, PODCASTS (+6 more)
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 0.15
|
||||
Nodes (8): MagicTextGroupProps, MagicTextProps, WordProps, formatPrice(), LOREM_PARAGRAPHS, ReportDetail(), TOC_ITEMS, TYPE_LABELS
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 0.15
|
||||
Nodes (9): Area, RadarTechSection(), RESEARCH_AREAS, T, CATEGORY_ICON, CATEGORY_LABEL, Radar(), RadarItemModal() (+1 more)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 0.23
|
||||
Nodes (12): buildMonth(), CBox(), EVENT_IMGS, Events(), faNum(), HERO_TARGET, J_MONTHS, jParts() (+4 more)
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 0.23
|
||||
Nodes (8): router, bootstrapPlans(), Plan, PlanCtaVariant, plans, bootstrapTeam(), team, TeamMember
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 0.27
|
||||
Nodes (10): FinancialScoreCards(), Card(), CardAction(), CardContent(), CardDescription(), CardFooter(), CardHeader(), CardTitle() (+2 more)
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 0.25
|
||||
Nodes (10): Header(), useAuth(), useLang(), AuthPage(), PostDetail(), ProfilePage(), RadarPost(), CONTENT (+2 more)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 0.18
|
||||
Nodes (8): CardDetail, L, Props, buildIndex(), KPIS, T, Technology(), TECHS
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 0.18
|
||||
Nodes (9): bootstrapPosts(), CommentTag, getPost(), Post, PostBlock, PostComment, POSTS, RELATED (+1 more)
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 0.18
|
||||
Nodes (5): Expert, Experts(), FEATURED_EXPERTS, ROOM_EXPERTS, TR
|
||||
|
||||
### Community 43 - "Community 43"
|
||||
Cohesion: 0.18
|
||||
Nodes (9): cats, DATA, DATES_EN, DATES_FA, __dirname, envPath, IMGS, pool (+1 more)
|
||||
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 0.20
|
||||
Nodes (3): FAQS, FEATURES, PLAN_EXTRA_FEATURES
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): upsertPrice(), fetchPage(), parseChangeCell(), parsePrices(), scrapeOnce(), SOURCES, toNumber()
|
||||
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 0.20
|
||||
Nodes (6): FA_DIGITS, IMG, MONTHS, pool, SOURCES, TAGS
|
||||
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 0.25
|
||||
Nodes (4): FinancialScore(), FinancialScoreHalfCircle(), Utils, newOtp()
|
||||
|
||||
### Community 48 - "Community 48"
|
||||
Cohesion: 0.22
|
||||
Nodes (6): bootstrapMedia(), MediaLangText, PodcastItem, podcasts, VideoItem, videos
|
||||
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): bootstrapReports(), Report, ReportCategory, reports, ReportType, Result, SearchResults()
|
||||
|
||||
### Community 50 - "Community 50"
|
||||
Cohesion: 0.25
|
||||
Nodes (4): bootstrapEvents(), Event, events, EventType
|
||||
|
||||
### Community 51 - "Community 51"
|
||||
Cohesion: 0.25
|
||||
Nodes (6): bootstrapFactoryReports(), FACTORY_REPORTS, FACTORY_REPORTS_META, FactoryReport, SCROLL_DIRS, ScrollDir
|
||||
|
||||
### Community 52 - "Community 52"
|
||||
Cohesion: 0.25
|
||||
Nodes (6): bootstrapIntegrations(), ICON_TYPES, Integration, IntegrationIcon, INTEGRATIONS, INTEGRATIONS_META
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
Cohesion: 0.29
|
||||
Nodes (5): formatPrice(), MONTH_ORDER, ReportRow(), TYPE_COLORS, TYPE_LABELS
|
||||
|
||||
### Community 54 - "Community 54"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): Geopolitics(), KPIS, LEVEL_COLOR, LEVEL_LABEL, SCENARIOS, SIGNALS, T
|
||||
|
||||
### Community 55 - "Community 55"
|
||||
Cohesion: 0.25
|
||||
Nodes (6): fallbackRiskItems, LEVEL, RiskItem, RiskLevel, RiskSection(), T
|
||||
|
||||
### Community 56 - "Community 56"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): INITIATIVES, KPIS, LEVEL_COLOR, LEVEL_LABEL, SIGNALS, Sustainability(), T
|
||||
|
||||
### Community 57 - "Community 57"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Button, ButtonProps, buttonVariants, LiquidButton(), liquidbuttonVariants
|
||||
|
||||
### Community 58 - "Community 58"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): Contact(), Errors, FormState, inputStyle(), tr
|
||||
|
||||
### Community 59 - "Community 59"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): advisoryBoard, AdvisoryMember, bootstrapAbout(), VisionItem, visionItems
|
||||
|
||||
### Community 60 - "Community 60"
|
||||
Cohesion: 0.33
|
||||
Nodes (4): bootstrapRisks(), RiskAlert, riskAlerts, RiskLevel
|
||||
|
||||
### Community 61 - "Community 61"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): AtAGlanceSection(), Glance, ITEMS
|
||||
|
||||
### Community 62 - "Community 62"
|
||||
Cohesion: 0.47
|
||||
Nodes (4): CompactCard(), formatPrice(), SpecialCard(), typeLabel
|
||||
|
||||
### Community 63 - "Community 63"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): faNum(), GeopoliticsModule(), MegaTrendsModule(), tchip(), TechRadarModule(), th()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **516 isolated node(s):** `{ run: runStats }`, `fs`, `path`, `name`, `private` (+511 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **10 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Utils` connect `Community 47` to `Community 16`?**
|
||||
_High betweenness centrality (0.161) - this node is a cross-community bridge._
|
||||
- **Why does `newOtp()` connect `Community 47` to `Community 1`?**
|
||||
_High betweenness centrality (0.159) - this node is a cross-community bridge._
|
||||
- **Why does `parsePrices()` connect `Community 45` to `Community 0`?**
|
||||
_High betweenness centrality (0.070) - this node is a cross-community bridge._
|
||||
- **Are the 31 inferred relationships involving `useLang()` (e.g. with `Header()` and `DigitLocalizer()`) actually correct?**
|
||||
_`useLang()` has 31 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `{ run: runStats }`, `fs`, `path` to the rest of the system?**
|
||||
_528 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.09394205443371378 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 1` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.03696741854636591 - nodes in this community are weakly interconnected._
|
||||
|
||||
## Build Notes
|
||||
|
||||
- AST/code graph completed.
|
||||
- Semantic docs/images extraction was incomplete in this Codex run; rebuild with GEMINI_API_KEY/GOOGLE_API_KEY or another graphify backend for full semantic coverage.
|
||||
- Video transcription skipped because faster-whisper is not installed.
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_init_py", "label": "__init__.py", "file_type": "code", "source_file": ".agents/skills/caveman-compress/scripts/__init__.py", "source_location": "L1"}, {"id": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_init_rationale_1", "label": "Caveman compress scripts. This package provides tools to compress natural lan", "file_type": "rationale", "source_file": ".agents/skills/caveman-compress/scripts/__init__.py", "source_location": "L1"}], "edges": [{"source": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_init_rationale_1", "target": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": ".agents/skills/caveman-compress/scripts/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (non-object root)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (non-object root)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_json", "label": "tsconfig.json", "file_type": "code", "source_file": "frontend/tsconfig.json", "source_location": "L1"}, {"id": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_files", "label": "files", "file_type": "code", "source_file": "frontend/tsconfig.json", "source_location": "L2"}, {"id": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_references", "label": "references", "file_type": "code", "source_file": "frontend/tsconfig.json", "source_location": "L3"}], "edges": [{"source": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_json", "target": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_files", "relation": "contains", "confidence": "EXTRACTED", "source_file": "frontend/tsconfig.json", "source_location": "L2", "weight": 1.0}, {"source": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_json", "target": "c_users_datis_star_andishkade_foolad_frontend_tsconfig_references", "relation": "contains", "confidence": "EXTRACTED", "source_file": "frontend/tsconfig.json", "source_location": "L3", "weight": 1.0}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_main_py", "label": "__main__.py", "file_type": "code", "source_file": ".agents/skills/caveman-compress/scripts/__main__.py", "source_location": "L1"}], "edges": [{"source": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_main_py", "target": "c_users_datis_star_andishkade_foolad_agents_skills_caveman_compress_scripts_cli_py", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": ".agents/skills/caveman-compress/scripts/__main__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-07-04T08:02:40.729293+00:00",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"files": 273,
|
||||
"note": "AST-only build; semantic extraction incomplete"
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,566 @@
|
|||
import * as cheerio from 'cheerio';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36';
|
||||
const STATUS_BY_JOB = new Map();
|
||||
let seq = 0;
|
||||
|
||||
const CFG = {
|
||||
ai: {
|
||||
baseUrl: process.env.AI_BASE_URL || 'https://api.openai.com/v1',
|
||||
apiKey: process.env.AI_API_KEY || '',
|
||||
model: process.env.AI_MODEL || 'gpt-4.1-mini',
|
||||
},
|
||||
panel: {
|
||||
baseUrl: process.env.PANEL_BASE_URL || process.env.PUBLIC_ORIGIN || 'http://localhost:3000',
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || '',
|
||||
},
|
||||
systemPrompt: `You are a Persian steel-sector editorial generator.
|
||||
|
||||
Return JSON only.
|
||||
|
||||
For "At a Glance":
|
||||
- short, compact, 2-3 sentences
|
||||
- one takeaway
|
||||
- no long analysis
|
||||
|
||||
For "Radar Future":
|
||||
- long-form analytical article
|
||||
- 4-5 paragraphs
|
||||
- multiple sources and in-text citations
|
||||
- intro, build-up, conclusion
|
||||
- neutral, formal Persian
|
||||
|
||||
Do not mix the two formats.
|
||||
Do not invent facts. Use only the provided news.`
|
||||
};
|
||||
|
||||
const AT_A_GLANCE_CATEGORIES = [
|
||||
{ code: 'market', label: 'بازارهای جهانی و اقتصاد کلان', query: 'فولاد بازار قیمت' },
|
||||
{ code: 'tech', label: 'نوآوری و فناوریهای تحولآفرین', query: 'فولاد فناوری نوآوری' },
|
||||
{ code: 'commodity', label: 'مواد معدنی حیاتی و زنجیره تامین', query: 'سنگ آهن قراضه فولاد' },
|
||||
{ code: 'geo', label: 'ژئوپلیتیک و امنیت اقتصادی', query: 'فولاد تعرفه تحریم ژئوپلیتیک' },
|
||||
{ code: 'energy', label: 'تجارت و کسبوکار بینالملل', query: 'فولاد انرژی صادرات تجارت' },
|
||||
];
|
||||
|
||||
const RADAR_FUTURE_CATEGORIES = [
|
||||
{ label: 'بازار و زنجیره فولاد', query: 'steel market supply chain pricing tariffs' },
|
||||
{ label: 'پایداری و فولاد سبز', query: 'green steel hydrogen decarbonization' },
|
||||
{ label: 'ژئوپلیتیک و اقتصاد جهانی', query: 'steel geopolitics tariffs sanctions trade' },
|
||||
{ label: 'فناوری و نوآوری', query: 'steel automation ai digital twin robotics' },
|
||||
];
|
||||
|
||||
const RADAR_FUTURE_PROFILES = {
|
||||
'بازار و زنجیره فولاد': 'Focus on prices, supply-demand balance, iron ore, scrap, coking coal, capacity, trade flows, logistics, inventory, margins, and steel value-chain risk.',
|
||||
'پایداری و فولاد سبز': 'Focus on green steel, hydrogen DRI, EAF, emissions, CBAM, ETS, renewable power, regulation, capital expenditure, and industrial decarbonization.',
|
||||
'ژئوپلیتیک و اقتصاد جهانی': 'Focus on tariffs, sanctions, industrial policy, China, US, EU, India, supply security, trade conflict, currency pressure, and geopolitical risk.',
|
||||
'فناوری و نوآوری': 'Focus on AI, automation, robotics, sensors, digital twins, process control, predictive maintenance, advanced materials, and steel plant productivity.',
|
||||
};
|
||||
|
||||
function buildSystemPrompt(kind, categoryLabel) {
|
||||
if (kind === 'radarFuture') {
|
||||
return `You are the senior Persian editor for the "Radar Future" section of a steel-industry think tank.
|
||||
|
||||
Section category: ${categoryLabel}
|
||||
Category specialization: ${RADAR_FUTURE_PROFILES[categoryLabel] || 'Steel-industry foresight and market analysis.'}
|
||||
|
||||
Output JSON only.
|
||||
|
||||
Radar Future requirements:
|
||||
- Write in formal Persian.
|
||||
- Target length: about 900 Persian words, acceptable range 800-1000 words.
|
||||
- Analytical article, not a short news brief.
|
||||
- Build an introduction, evidence-led body, strategic implications, and conclusion.
|
||||
- Use multiple sources. Prefer at least 3 distinct source links when provided.
|
||||
- Use numbered in-text citations exactly like [1], [2], [3].
|
||||
- Citation [1] must correspond to sources[0], citation [2] to sources[1], and so on.
|
||||
- Every source in sources[] should be cited at least once in the text.
|
||||
- Do not invent facts. Use only the provided source material.
|
||||
- Do not add image blocks, chart blocks, image URLs, or captions. The editor will add images manually.
|
||||
- blocks[] must contain only {"type":"text","value":"..."} objects.`;
|
||||
}
|
||||
return `You are the senior Persian editor for the "At a Glance" section of a steel-industry think tank.
|
||||
|
||||
Output JSON only.
|
||||
|
||||
At a Glance requirements:
|
||||
- Write in formal Persian.
|
||||
- Short, compact, and decision-useful.
|
||||
- 2-3 sentences per item.
|
||||
- One clear takeaway per item.
|
||||
- No long essay.
|
||||
- Do not invent facts. Use only the provided source material.`;
|
||||
}
|
||||
|
||||
function publicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
kind: job.kind,
|
||||
input: job.input,
|
||||
status: job.status,
|
||||
step: job.step,
|
||||
progress: job.progress,
|
||||
message: job.message,
|
||||
createdAt: job.createdAt,
|
||||
startedAt: job.startedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
error: job.error,
|
||||
result: job.result,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJob(id) {
|
||||
return STATUS_BY_JOB.get(id) || null;
|
||||
}
|
||||
|
||||
export function listJobs() {
|
||||
return [...STATUS_BY_JOB.values()].map(publicJob);
|
||||
}
|
||||
|
||||
function createJob(kind, input) {
|
||||
const id = `gen_${Date.now()}_${++seq}`;
|
||||
const job = {
|
||||
id,
|
||||
kind,
|
||||
input,
|
||||
status: 'queued',
|
||||
step: 'waiting',
|
||||
progress: 0,
|
||||
message: 'queued',
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
result: null,
|
||||
};
|
||||
STATUS_BY_JOB.set(id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
function updateJob(job, patch) {
|
||||
Object.assign(job, patch);
|
||||
return publicJob(job);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function decode(s = '') {
|
||||
return s
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, init = {}, ms = 15000) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), ms);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: ctrl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNews(query) {
|
||||
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query + ' when:30d')}&hl=en-US&gl=US&ceid=US:en`;
|
||||
const sources = [
|
||||
{ name: 'google-rss', url, init: { headers: { 'User-Agent': UA } } },
|
||||
{ name: 'jina-rss', url: `https://r.jina.ai/http://${url.replace(/^https?:\/\//, '')}`, init: {} },
|
||||
];
|
||||
let xml = '';
|
||||
let lastErr = null;
|
||||
for (const src of sources) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(src.url, src.init, 15000);
|
||||
if (!res.ok) throw new Error(`news HTTP ${res.status}`);
|
||||
xml = await res.text();
|
||||
if (xml.trim()) break;
|
||||
} catch (err) {
|
||||
lastErr = new Error(`${src.name}: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
if (!xml.trim()) throw new Error(`news fetch failed: ${lastErr?.message || 'no source returned content'}`);
|
||||
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => {
|
||||
const block = m[1];
|
||||
const t = (tag) => decode(block.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`))?.[1] || '');
|
||||
return { title: t('title'), link: t('link'), pubDate: t('pubDate'), source: t('source') };
|
||||
});
|
||||
return items.filter((x) => x.title);
|
||||
}
|
||||
|
||||
function sourceHost(url) {
|
||||
try { return new URL(url).hostname.replace(/^www\./, ''); }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
function trimSourceText(text, max = 3500) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||
}
|
||||
|
||||
function extractReadableText(raw, contentType = '', url = '') {
|
||||
if (!raw) return null;
|
||||
if (/html/i.test(contentType) || /<html|<body|<article|<!doctype/i.test(raw.slice(0, 500))) {
|
||||
const $ = cheerio.load(raw);
|
||||
$('script,style,noscript,svg,iframe,nav,footer,header').remove();
|
||||
const title = trimSourceText($('meta[property="og:title"]').attr('content') || $('title').first().text() || $('h1').first().text() || url, 220);
|
||||
const text = trimSourceText($('article').text() || $('main').text() || $('body').text());
|
||||
return text ? { title, text } : null;
|
||||
}
|
||||
const titleMatch = raw.match(/^Title:\s*(.+)$/im);
|
||||
const title = trimSourceText(titleMatch?.[1] || url, 220);
|
||||
const text = trimSourceText(raw);
|
||||
return text ? { title, text } : null;
|
||||
}
|
||||
|
||||
function jinaReaderUrl(url) {
|
||||
const u = String(url || '').replace(/^https?:\/\//, '');
|
||||
return `https://r.jina.ai/http://${u}`;
|
||||
}
|
||||
|
||||
async function fetchReadableUrl(url) {
|
||||
const attempts = [
|
||||
{ name: 'direct', url },
|
||||
{ name: 'reader', url: jinaReaderUrl(url) },
|
||||
];
|
||||
let lastErr = null;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(attempt.url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Accept: 'text/html,text/plain,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const raw = await res.text();
|
||||
const parsed = extractReadableText(raw, res.headers.get('content-type') || '', url);
|
||||
if (parsed?.text) return { ...parsed, url };
|
||||
} catch (err) {
|
||||
lastErr = new Error(`${attempt.name}: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('read failed');
|
||||
}
|
||||
|
||||
async function collectSourceMaterials(query, sourceRows = [], maxItems = 12) {
|
||||
const rows = Array.isArray(sourceRows) ? sourceRows.filter((s) => s?.url).slice(0, 80) : [];
|
||||
if (!rows.length) return [];
|
||||
const materials = [];
|
||||
for (const src of rows) {
|
||||
if (materials.length >= maxItems) break;
|
||||
const host = sourceHost(src.url);
|
||||
const type = src.sourceType || src.source_type || 'auto';
|
||||
const label = src.label || host || src.url;
|
||||
try {
|
||||
if (type === 'link' || (new URL(src.url).pathname || '/') !== '/') {
|
||||
const page = await fetchReadableUrl(src.url);
|
||||
materials.push({ ...page, sourceLabel: label });
|
||||
} else {
|
||||
const hits = (await fetchNews(`${query} site:${host}`)).slice(0, 2);
|
||||
for (const hit of hits) {
|
||||
if (materials.length >= maxItems) break;
|
||||
try {
|
||||
const page = await fetchReadableUrl(hit.link);
|
||||
materials.push({ ...page, title: page.title || hit.title, sourceLabel: label });
|
||||
} catch {
|
||||
materials.push({ title: hit.title, url: hit.link, sourceLabel: label, text: hit.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A large source library will always have a few blocked sites. Keep the job moving.
|
||||
}
|
||||
}
|
||||
return materials;
|
||||
}
|
||||
|
||||
function sourceMaterialsText(materials) {
|
||||
return materials.map((m, i) => `${i + 1}. ${m.title || m.sourceLabel || 'Source'}
|
||||
source: ${m.sourceLabel || ''}
|
||||
url: ${m.url || ''}
|
||||
excerpt: ${trimSourceText(m.text, 2200)}`).join('\n\n');
|
||||
}
|
||||
|
||||
function uniqueSourceList(materials, max = 6) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const m of materials || []) {
|
||||
if (!m?.url || seen.has(m.url)) continue;
|
||||
seen.add(m.url);
|
||||
out.push({ title: m.title || m.sourceLabel || sourceHost(m.url) || m.url, url: m.url });
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function newsToMaterials(news) {
|
||||
return (news || []).map((n) => ({
|
||||
title: n.title,
|
||||
url: n.link,
|
||||
sourceLabel: n.source || sourceHost(n.link),
|
||||
text: n.title,
|
||||
}));
|
||||
}
|
||||
|
||||
function textOnlyBlocks(blocks, fallbackText = '') {
|
||||
const out = (Array.isArray(blocks) ? blocks : [])
|
||||
.filter((b) => b?.type === 'text' && String(b.value || '').trim())
|
||||
.map((b) => ({ type: 'text', value: String(b.value || '').trim() }));
|
||||
if (!out.length && fallbackText) out.push({ type: 'text', value: String(fallbackText).trim() });
|
||||
return out;
|
||||
}
|
||||
|
||||
function alignCitations(blocks, sources) {
|
||||
if (!blocks.length || !sources.length) return blocks;
|
||||
const maxSource = sources.length;
|
||||
for (const b of blocks) {
|
||||
b.value = String(b.value || '').replace(/\[(\d+)\]/g, (m, n) => {
|
||||
const idx = Number(n);
|
||||
return idx >= 1 && idx <= maxSource ? m : '';
|
||||
}).replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
const joined = blocks.map((b) => b.value).join('\n');
|
||||
const cited = new Set([...joined.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1])));
|
||||
const minimum = Math.min(3, maxSource);
|
||||
for (let i = 1; i <= minimum; i++) {
|
||||
if (cited.has(i)) continue;
|
||||
const target = blocks[Math.min(i - 1, blocks.length - 1)];
|
||||
target.value = `${target.value.replace(/[.。؟?]*$/, '')} [${i}].`;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractStringField(text, key) {
|
||||
const rx = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 's');
|
||||
const m = text.match(rx);
|
||||
return m ? m[1]
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.trim() : '';
|
||||
}
|
||||
|
||||
function extractArrayField(text, key) {
|
||||
const rx = new RegExp(`"${key}"\\s*:\\s*(\\[[\\s\\S]*?\\])`, 's');
|
||||
const m = text.match(rx);
|
||||
if (!m) return [];
|
||||
try {
|
||||
return JSON.parse(m[1].replace(/,(\s*[}\]])/g, '$1'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function salvageJson(text) {
|
||||
const cleaned = String(text || '')
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim();
|
||||
const obj = {
|
||||
titleFa: extractStringField(cleaned, 'titleFa'),
|
||||
titleEn: extractStringField(cleaned, 'titleEn'),
|
||||
publishDateFa: extractStringField(cleaned, 'publishDateFa'),
|
||||
publishDateEn: extractStringField(cleaned, 'publishDateEn'),
|
||||
summaryFa: extractStringField(cleaned, 'summaryFa'),
|
||||
summaryEn: extractStringField(cleaned, 'summaryEn'),
|
||||
authorName: extractStringField(cleaned, 'authorName'),
|
||||
authorAvatar: extractStringField(cleaned, 'authorAvatar'),
|
||||
tags: extractArrayField(cleaned, 'tags'),
|
||||
sources: extractArrayField(cleaned, 'sources'),
|
||||
blocks: extractArrayField(cleaned, 'blocks'),
|
||||
};
|
||||
if (!obj.blocks.length && cleaned) obj.blocks = [{ type: 'text', value: cleaned.slice(0, 900) }];
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function aiJson(messages, maxTokens = 6000) {
|
||||
if (!CFG.ai.apiKey) throw new Error('AI_API_KEY is missing');
|
||||
let res;
|
||||
try {
|
||||
res = await fetchWithTimeout(`${CFG.ai.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${CFG.ai.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: CFG.ai.model, messages, max_tokens: maxTokens, temperature: 0.7 }),
|
||||
}, 45000);
|
||||
} catch (err) {
|
||||
throw new Error(`ai fetch failed: ${err.message || err}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(`ai ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
const j = await res.json();
|
||||
const text = String(j.choices?.[0]?.message?.content || '').trim();
|
||||
const candidates = [
|
||||
text,
|
||||
text.replace(/^```(?:json)?\s*/i, '').replace(/```$/i, '').trim(),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const s = candidate.indexOf('{');
|
||||
const e = candidate.lastIndexOf('}');
|
||||
if (s === -1 || e === -1) continue;
|
||||
const body = candidate.slice(s, e + 1)
|
||||
.replace(/,(\s*[}\]])/g, '$1')
|
||||
.replace(/^\uFEFF/, '');
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {}
|
||||
}
|
||||
const salvage = salvageJson(text);
|
||||
if (salvage.titleFa || salvage.summaryFa || salvage.blocks.length || salvage.sources.length) return salvage;
|
||||
throw new Error(`ai json parse failed: ${text.slice(0, 240)}`);
|
||||
}
|
||||
|
||||
async function login(panelBaseUrl, username, password) {
|
||||
const res = await fetch(`${panelBaseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`panel login failed (${res.status})`);
|
||||
const cookies = res.headers.getSetCookie?.() || [];
|
||||
const session = cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith('session='));
|
||||
if (!session) throw new Error('no session cookie returned');
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createRadarDraft(panelBaseUrl, session, payload) {
|
||||
const res = await fetch(`${panelBaseUrl}/api/radar`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: session },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`radar POST ${res.status}: ${(await res.text()).slice(0, 140)}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createArticleDraft(panelBaseUrl, session, payload) {
|
||||
const res = await fetch(`${panelBaseUrl}/api/articles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: session },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(`article POST ${res.status}: ${(await res.text()).slice(0, 140)}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function generateAtAGlance(job, panelBaseUrl, session) {
|
||||
updateJob(job, { status: 'running', startedAt: new Date().toISOString(), step: 'fetching', progress: 10, message: 'fetching news' });
|
||||
const cat = AT_A_GLANCE_CATEGORIES.find((c) => c.code === job.input.category) || AT_A_GLANCE_CATEGORIES[0];
|
||||
const trusted = await collectSourceMaterials(cat.query, job.input.sources, 10);
|
||||
const news = trusted.length ? [] : (await fetchNews(cat.query)).slice(0, 8);
|
||||
updateJob(job, { step: 'generating', progress: 35, message: trusted.length ? `using ${trusted.length} saved source items` : `news ${news.length} items` });
|
||||
const newsText = trusted.length
|
||||
? sourceMaterialsText(trusted)
|
||||
: news.map((n, i) => `${i + 1}. ${n.title}\nsource: ${n.link}`).join('\n\n');
|
||||
const ai = await aiJson([
|
||||
{ role: 'system', content: buildSystemPrompt('glance', cat.label) },
|
||||
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material.\n\n${newsText}` },
|
||||
], 7000);
|
||||
const items = Array.isArray(ai?.items) ? ai.items : [];
|
||||
updateJob(job, { step: 'saving', progress: 70, message: `drafting ${items.length} items` });
|
||||
const dateFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date());
|
||||
const saved = [];
|
||||
for (const item of items.slice(0, 3)) {
|
||||
const row = await createRadarDraft(panelBaseUrl, session, {
|
||||
category: cat.code,
|
||||
titleFa: String(item.titleFa || '').trim(),
|
||||
excerptFa: item.excerptFa || null,
|
||||
bodyFa: item.bodyFa || null,
|
||||
analysisFa: item.analysisFa || null,
|
||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||
source: item.sourceUrl || null,
|
||||
dateFa,
|
||||
status: 'draft',
|
||||
});
|
||||
saved.push(row.id);
|
||||
}
|
||||
updateJob(job, { status: 'done', step: 'done', progress: 100, message: 'finished', finishedAt: new Date().toISOString(), result: { ids: saved } });
|
||||
}
|
||||
|
||||
async function generateRadarFuture(job, panelBaseUrl, session) {
|
||||
updateJob(job, { status: 'running', startedAt: new Date().toISOString(), step: 'fetching', progress: 10, message: 'fetching news' });
|
||||
const selected = job.input.category && job.input.category !== 'all'
|
||||
? RADAR_FUTURE_CATEGORIES.filter((c) => c.label === job.input.category)
|
||||
: RADAR_FUTURE_CATEGORIES;
|
||||
const saved = [];
|
||||
for (let idx = 0; idx < selected.length; idx++) {
|
||||
const cat = selected[idx];
|
||||
const pct = 10 + Math.round((idx / Math.max(selected.length, 1)) * 70);
|
||||
updateJob(job, { step: 'fetching', progress: pct, message: `fetching sources for ${cat.label}` });
|
||||
const trusted = await collectSourceMaterials(cat.query, job.input.sources, 14);
|
||||
const news = trusted.length ? [] : (await fetchNews(cat.query)).slice(0, 12);
|
||||
updateJob(job, { step: 'generating', progress: pct + 10, message: trusted.length ? `writing ${cat.label} from ${trusted.length} saved source items` : `writing ${cat.label} from ${news.length} news items` });
|
||||
const materials = (trusted.length ? trusted : newsToMaterials(news)).slice(0, 6);
|
||||
const citationSources = uniqueSourceList(materials, 6);
|
||||
const newsText = sourceMaterialsText(materials);
|
||||
const ai = await aiJson([
|
||||
{ role: 'system', content: buildSystemPrompt('radarFuture', cat.label) },
|
||||
{ role: 'user', content: `Write one long-form Radar Future article for category "${cat.label}".
|
||||
|
||||
Return JSON only in this exact shape:
|
||||
{"titleFa":"","titleEn":"","publishDateFa":"","publishDateEn":"","summaryFa":"","summaryEn":"","authorName":"","authorAvatar":"","tags":[],"sources":[{"title":"","url":""}],"blocks":[{"type":"text","value":""}]}
|
||||
|
||||
Rules:
|
||||
- Target about 900 Persian words, acceptable range 800-1000.
|
||||
- Use 5-7 paragraph-style text blocks.
|
||||
- Do not include images, image blocks, chart blocks, img fields, image URLs, or captions.
|
||||
- Use multiple source mentions in the body.
|
||||
- Cite facts with numbered in-text citations like [1], [2], [3].
|
||||
- Citation [1] must map to sources[0], [2] to sources[1], etc.
|
||||
- Keep sources[] in the same order as the citation numbers.
|
||||
- Use at least 3 source links if at least 3 are provided below.
|
||||
- Intro, build-up, strategic implications, conclusion.
|
||||
- Keep it analytical.
|
||||
- Use only facts supported by the saved source material/news.
|
||||
- Keep sources array to the strongest 3-5 exact links.
|
||||
- Return a single valid JSON object only. No markdown, no code fences, no explanation text.
|
||||
|
||||
Available source material, already numbered for citation use:
|
||||
${newsText}` },
|
||||
], 12000);
|
||||
updateJob(job, { step: 'saving', progress: pct + 18, message: `saving ${cat.label}` });
|
||||
const blocks = alignCitations(textOnlyBlocks(ai?.blocks, ai?.body || ''), citationSources);
|
||||
const article = await createArticleDraft(panelBaseUrl, session, {
|
||||
title: ai?.titleFa || cat.label,
|
||||
category: cat.label,
|
||||
type: 'radarfuture',
|
||||
author: ai?.authorName || 'تحریریه',
|
||||
authorRole: 'تحریریه',
|
||||
authorInitial: 'ت',
|
||||
publishDate: ai?.publishDateFa || new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date()),
|
||||
pages: 0,
|
||||
price: 0,
|
||||
isFree: true,
|
||||
summary: ai?.summaryFa || '',
|
||||
body: blocks.map((b) => b.value).join('\n\n'),
|
||||
coverImage: '',
|
||||
blocks,
|
||||
sources: citationSources.slice(0, Math.max(3, Math.min(citationSources.length, 6))),
|
||||
tags: Array.isArray(ai?.tags) ? ai.tags : [],
|
||||
featured: false,
|
||||
status: 'draft',
|
||||
keyData: null,
|
||||
type: 'radarfuture',
|
||||
});
|
||||
saved.push({ id: article.id, category: cat.label });
|
||||
await sleep(50);
|
||||
}
|
||||
updateJob(job, { status: 'done', step: 'done', progress: 100, message: 'finished', finishedAt: new Date().toISOString(), result: { articles: saved } });
|
||||
}
|
||||
|
||||
export async function runGenerator(input) {
|
||||
const kind = input.kind === 'radarFuture' ? 'radarFuture' : 'glance';
|
||||
const job = createJob(kind, input);
|
||||
const panelBaseUrl = input.panelBaseUrl || CFG.panel.baseUrl;
|
||||
const session = input.sessionCookie || await login(panelBaseUrl, input.username || CFG.panel.username, input.password || CFG.panel.password);
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
if (kind === 'radarFuture') await generateRadarFuture(job, panelBaseUrl, session);
|
||||
else await generateAtAGlance(job, panelBaseUrl, session);
|
||||
} catch (err) {
|
||||
updateJob(job, { status: 'error', step: 'error', progress: job.progress || 0, message: String(err.message || err), finishedAt: new Date().toISOString(), error: String(err.message || err) });
|
||||
}
|
||||
});
|
||||
return publicJob(job);
|
||||
}
|
||||
14
panel/db.js
14
panel/db.js
|
|
@ -126,6 +126,19 @@ await pool.query(`
|
|||
CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generator_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
source_type TEXT NOT NULL DEFAULT 'auto',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
notes TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_generator_sources_sort ON generator_sources(sort_order, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS risk_signals (
|
||||
id TEXT PRIMARY KEY,
|
||||
quote TEXT NOT NULL,
|
||||
|
|
@ -790,6 +803,7 @@ export function rowToArticle(row) {
|
|||
sources: row.sources ? JSON.parse(row.sources) : [],
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
featured: !!row.featured,
|
||||
status: row.status || 'published',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ function topbarHtml(active, primaryLabel) {
|
|||
<nav class="groups">${groupBtns}</nav>
|
||||
<div class="actions">
|
||||
<button id="newBtn" class="primary">${primaryLabel}</button>
|
||||
<button id="genBtn" class="ghost">+ تولید</button>
|
||||
<button id="logoutBtn" class="ghost">خروج</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,6 +196,118 @@ function wireTabs() {
|
|||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
|
||||
renderLogin();
|
||||
});
|
||||
$('#genBtn')?.addEventListener('click', () => openGeneratorDialog());
|
||||
}
|
||||
|
||||
function openGeneratorDialog(defaultKind = 'glance') {
|
||||
const existing = document.getElementById('generatorModal');
|
||||
if (existing) existing.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'generatorModal';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:1000;display:grid;place-items:center;';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-backdrop" style="position:absolute;inset:0;background:rgba(15,23,42,.55)"></div>
|
||||
<div class="modal-card" style="position:relative;z-index:1;width:min(720px,calc(100vw - 32px));background:#fff;border:1px solid #dbe3ee;border-radius:12px;padding:18px;box-shadow:0 20px 50px rgba(15,23,42,.25)">
|
||||
<div class="modal-head" style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<strong>تولید محتوا</strong>
|
||||
<button type="button" id="closeGen" class="ghost">بستن</button>
|
||||
</div>
|
||||
<div style="display:grid;gap:12px">
|
||||
<label>نوع محتوا
|
||||
<select id="genKind">
|
||||
<option value="glance">در یک نگاه</option>
|
||||
<option value="radarFuture">رادار آینده</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>دسته
|
||||
<select id="genCategory"></select>
|
||||
</label>
|
||||
<label>منابع و لینکها
|
||||
<textarea id="genSources" rows="8" dir="ltr" placeholder="هر خط یک دامنه یا لینک:
|
||||
mckinsey.com
|
||||
worldsteel.org
|
||||
عنوان منبع | https://example.com/report"></textarea>
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap">
|
||||
<span class="muted" id="sourceStatus">در حال بارگذاری منابع...</span>
|
||||
<button type="button" id="saveSources" class="ghost">ذخیره منابع</button>
|
||||
</div>
|
||||
<div class="muted" id="genHint">برای رادار آینده، انتخاب «همه» هر ۴ زیرسرفصل را draft میکند.</div>
|
||||
<div id="genStatus" class="muted" style="min-height:24px"></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button type="button" id="startGen" class="primary">شروع تولید</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const kindEl = overlay.querySelector('#genKind');
|
||||
const catEl = overlay.querySelector('#genCategory');
|
||||
const statusEl = overlay.querySelector('#genStatus');
|
||||
const hintEl = overlay.querySelector('#genHint');
|
||||
const sourceEl = overlay.querySelector('#genSources');
|
||||
const sourceStatusEl = overlay.querySelector('#sourceStatus');
|
||||
const categories = {
|
||||
glance: [{ value: 'market', label: 'بازارهای جهانی و اقتصاد کلان' }, { value: 'tech', label: 'نوآوری و فناوریهای تحولآفرین' }, { value: 'commodity', label: 'مواد معدنی حیاتی و زنجیره تامین' }, { value: 'geo', label: 'ژئوپلیتیک و امنیت اقتصادی' }, { value: 'energy', label: 'تجارت و کسبوکار بینالملل' }],
|
||||
radarFuture: [{ value: 'all', label: 'همه' }, { value: 'بازار و زنجیره فولاد', label: 'بازار و زنجیره فولاد' }, { value: 'پایداری و فولاد سبز', label: 'پایداری و فولاد سبز' }, { value: 'ژئوپلیتیک و اقتصاد جهانی', label: 'ژئوپلیتیک و اقتصاد جهانی' }, { value: 'فناوری و نوآوری', label: 'فناوری و نوآوری' }],
|
||||
};
|
||||
const fillCats = () => {
|
||||
const list = categories[kindEl.value] || categories.glance;
|
||||
catEl.innerHTML = list.map(c => `<option value="${escapeAttr(c.value)}">${escapeHtml(c.label)}</option>`).join('');
|
||||
hintEl.textContent = kindEl.value === 'radarFuture' ? 'برای رادار آینده، انتخاب «همه» هر ۴ زیرسرفصل را draft میکند.' : 'در یک نگاه، چند آیتم کوتاه و فشرده ساخته میشود.';
|
||||
};
|
||||
kindEl.value = defaultKind;
|
||||
fillCats();
|
||||
kindEl.addEventListener('change', fillCats);
|
||||
overlay.querySelector('#closeGen').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.modal-backdrop').addEventListener('click', () => overlay.remove());
|
||||
const saveSources = async () => {
|
||||
const text = sourceEl.value || '';
|
||||
const data = await api('/api/admin/generator-sources', { method: 'PUT', body: { text } });
|
||||
sourceStatusEl.textContent = `${data.count || 0} منبع ذخیره شد`;
|
||||
return data;
|
||||
};
|
||||
api('/api/admin/generator-sources').then((data) => {
|
||||
sourceEl.value = data.text || '';
|
||||
sourceStatusEl.textContent = `${data.sources?.length || 0} منبع ذخیره شده`;
|
||||
}).catch((err) => {
|
||||
sourceStatusEl.textContent = `خطا در بارگذاری منابع: ${err.message}`;
|
||||
});
|
||||
overlay.querySelector('#saveSources').addEventListener('click', async () => {
|
||||
try { await saveSources(); }
|
||||
catch (err) { sourceStatusEl.textContent = `خطا در ذخیره منابع: ${err.message}`; }
|
||||
});
|
||||
overlay.querySelector('#startGen').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'در حال ذخیره منابع...';
|
||||
try { await saveSources(); }
|
||||
catch (err) { statusEl.textContent = `خطا در ذخیره منابع: ${err.message}`; return; }
|
||||
statusEl.textContent = 'در حال شروع...';
|
||||
const job = await api('/api/admin/generate', { method: 'POST', body: { kind: kindEl.value, category: catEl.value, sourcesText: sourceEl.value || '' } });
|
||||
statusEl.textContent = `${job.message} | ${job.step} | ${job.progress}%`;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const fresh = await api(`/api/admin/generate/jobs/${job.id}`);
|
||||
statusEl.textContent = `${fresh.message} | ${fresh.step} | ${fresh.progress}%`;
|
||||
if (fresh.status === 'done') {
|
||||
clearInterval(timer);
|
||||
statusEl.textContent = 'تمام شد';
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
if (kindEl.value === 'radarFuture') renderRadarFutureList();
|
||||
else renderRadarList();
|
||||
}, 700);
|
||||
}
|
||||
if (fresh.status === 'error') {
|
||||
clearInterval(timer);
|
||||
statusEl.textContent = `خطا: ${fresh.error || fresh.message}`;
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(timer);
|
||||
statusEl.textContent = `خطا: ${err.message}`;
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderList() {
|
||||
|
|
|
|||
166
panel/server.js
166
panel/server.js
|
|
@ -16,6 +16,7 @@ import fs from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { randomInt, createHmac, randomUUID } from 'node:crypto';
|
||||
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
|
||||
import { getJob, listJobs, runGenerator } from './content-generator.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT || 3001);
|
||||
|
|
@ -263,6 +264,23 @@ async function authRequired(req, res, next) {
|
|||
}
|
||||
}
|
||||
|
||||
async function hasAdminSession(req) {
|
||||
let token = req.cookies?.session;
|
||||
if (!token) {
|
||||
const auth = req.headers['authorization'];
|
||||
if (auth?.startsWith('Bearer ')) token = auth.slice(7);
|
||||
}
|
||||
if (!token) return false;
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
if (payload.role !== 'admin' && payload.role !== 'owner') return false;
|
||||
const acct = await db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub);
|
||||
return !!acct && (acct.token_version || 0) === (payload.tv || 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// only an 'owner' admin may manage other admin accounts
|
||||
function ownerRequired(req, res, next) {
|
||||
if (req.user?.role !== 'owner') return res.status(403).json({ error: 'forbidden' });
|
||||
|
|
@ -347,17 +365,19 @@ app.get('/api/auth/me', authRequired, (req, res) => {
|
|||
const PUBLIC_FIELDS = `
|
||||
id, title, category, type, author, author_role, author_initial,
|
||||
publish_date, pages, price, is_free, summary, body, cover_image,
|
||||
blocks, sources, tags, featured, created_at, updated_at
|
||||
blocks, sources, tags, featured, status, created_at, updated_at
|
||||
`;
|
||||
|
||||
app.get('/api/articles', async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const { category, type } = req.query;
|
||||
const canSeeDrafts = await hasAdminSession(req);
|
||||
let query = `SELECT ${PUBLIC_FIELDS} FROM articles`;
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
if (category) { conditions.push('category = ?'); params.push(category); }
|
||||
if (type) { conditions.push('type = ?'); params.push(type); }
|
||||
if (!canSeeDrafts) conditions.push("COALESCE(status,'published') = 'published'");
|
||||
if (conditions.length) query += ' WHERE ' + conditions.join(' AND ');
|
||||
query += ' ORDER BY created_at DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
|
@ -366,8 +386,10 @@ app.get('/api/articles', async (req, res) => {
|
|||
});
|
||||
|
||||
app.get('/api/articles/:id', async (req, res) => {
|
||||
const canSeeDrafts = await hasAdminSession(req);
|
||||
const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||
if (!canSeeDrafts && (row.status || 'published') !== 'published') return res.status(404).json({ error: 'not_found' });
|
||||
res.json(rowToArticle(row));
|
||||
});
|
||||
|
||||
|
|
@ -459,6 +481,7 @@ function normalizeArticleBody(b) {
|
|||
sources: JSON.stringify(Array.isArray(b.sources) ? b.sources : []),
|
||||
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
|
||||
featured: b.featured ? 1 : 0,
|
||||
status: ['draft', 'published'].includes(b.status) ? b.status : 'published',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -470,11 +493,11 @@ app.post('/api/articles', authRequired, async (req, res) => {
|
|||
INSERT INTO articles (
|
||||
id, title, category, type, author, author_role, author_initial,
|
||||
publish_date, pages, price, is_free, summary, body, cover_image,
|
||||
blocks, sources, tags, featured
|
||||
blocks, sources, tags, featured, status
|
||||
) VALUES (
|
||||
@id, @title, @category, @type, @author, @author_role, @author_initial,
|
||||
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
|
||||
@blocks, @sources, @tags, @featured
|
||||
@blocks, @sources, @tags, @featured, @status
|
||||
)
|
||||
`).run({ id, ...a });
|
||||
const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id);
|
||||
|
|
@ -493,7 +516,7 @@ app.put('/api/articles/:id', authRequired, async (req, res) => {
|
|||
publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
|
||||
summary = @summary, body = @body, cover_image = @cover_image,
|
||||
blocks = @blocks, sources = @sources,
|
||||
tags = @tags, featured = @featured,
|
||||
tags = @tags, featured = @featured, status = @status,
|
||||
updated_at = now()
|
||||
WHERE id = @id
|
||||
`).run({ id: req.params.id, ...a });
|
||||
|
|
@ -604,6 +627,139 @@ app.post('/api/admin/import', authRequired, async (req, res) => {
|
|||
res.json({ ok: true, ...results });
|
||||
});
|
||||
|
||||
// ---------- generator source library ----------
|
||||
function normalizeGeneratorSourceLine(line) {
|
||||
const raw = String(line || '').replace(/^[\s*-]+/, '').trim();
|
||||
if (!raw) return null;
|
||||
const parts = raw.split('|').map((p) => p.trim()).filter(Boolean);
|
||||
const label = parts.length > 1 ? parts[0] : '';
|
||||
let urlText = parts.length > 1 ? parts.slice(1).join('|').trim() : raw;
|
||||
if (!/^https?:\/\//i.test(urlText)) urlText = `https://${urlText}`;
|
||||
try {
|
||||
const u = new URL(urlText);
|
||||
u.hash = '';
|
||||
const isRoot = (!u.pathname || u.pathname === '/') && !u.search;
|
||||
const url = isRoot ? u.origin : u.toString().replace(/\/$/, '');
|
||||
return {
|
||||
label: label || u.hostname.replace(/^www\./, ''),
|
||||
url,
|
||||
sourceType: isRoot ? 'domain' : 'link',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGeneratorSourcesText(text) {
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const src = normalizeGeneratorSourceLine(line);
|
||||
if (!src || seen.has(src.url)) continue;
|
||||
seen.add(src.url);
|
||||
rows.push(src);
|
||||
}
|
||||
return rows.slice(0, 80);
|
||||
}
|
||||
|
||||
function sourceTextFromRows(rows) {
|
||||
return rows.map((r) => r.label ? `${r.label} | ${r.url}` : r.url).join('\n');
|
||||
}
|
||||
|
||||
async function saveGeneratorSources(text) {
|
||||
const sources = parseGeneratorSourcesText(text);
|
||||
await db.query('BEGIN');
|
||||
try {
|
||||
await db.query('DELETE FROM generator_sources');
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
await db.prepare(`
|
||||
INSERT INTO generator_sources (id, label, url, source_type, enabled, sort_order)
|
||||
VALUES (@id, @label, @url, @source_type, 1, @sort_order)
|
||||
`).run({
|
||||
id: nanoid(12),
|
||||
label: s.label,
|
||||
url: s.url,
|
||||
source_type: s.sourceType,
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
await db.query('COMMIT');
|
||||
} catch (err) {
|
||||
await db.query('ROLLBACK').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function getGeneratorSources() {
|
||||
return db.prepare(`
|
||||
SELECT id, label, url, source_type, enabled, notes, sort_order, created_at, updated_at
|
||||
FROM generator_sources
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
`).all();
|
||||
}
|
||||
|
||||
function publicGeneratorSource(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
sourceType: row.source_type,
|
||||
enabled: !!row.enabled,
|
||||
notes: row.notes,
|
||||
sortOrder: row.sort_order,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/admin/generator-sources', authRequired, async (_req, res) => {
|
||||
const rows = await getGeneratorSources();
|
||||
res.json({ sources: rows.map(publicGeneratorSource), text: sourceTextFromRows(rows) });
|
||||
});
|
||||
|
||||
app.put('/api/admin/generator-sources', authRequired, async (req, res) => {
|
||||
const rows = await saveGeneratorSources(req.body?.text || '');
|
||||
res.json({ ok: true, count: rows.length, sources: rows });
|
||||
});
|
||||
|
||||
// ---------- content generator jobs ----------
|
||||
app.post('/api/admin/generate', authRequired, async (req, res) => {
|
||||
const kind = req.body?.kind === 'radarFuture' ? 'radarFuture' : 'glance';
|
||||
const category = String(req.body?.category || '').trim();
|
||||
let sourceRows;
|
||||
if (typeof req.body?.sourcesText === 'string') {
|
||||
sourceRows = await saveGeneratorSources(req.body.sourcesText);
|
||||
} else {
|
||||
const rows = await db.prepare(`
|
||||
SELECT label, url, source_type
|
||||
FROM generator_sources
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
`).all();
|
||||
sourceRows = rows.map((r) => ({ label: r.label, url: r.url, sourceType: r.source_type }));
|
||||
}
|
||||
const job = await runGenerator({
|
||||
kind,
|
||||
category,
|
||||
sources: sourceRows,
|
||||
panelBaseUrl: process.env.PANEL_BASE_URL || process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`,
|
||||
sessionCookie: req.headers.cookie || '',
|
||||
});
|
||||
res.status(202).json(job);
|
||||
});
|
||||
|
||||
app.get('/api/admin/generate/jobs', authRequired, async (_req, res) => {
|
||||
res.json(listJobs());
|
||||
});
|
||||
|
||||
app.get('/api/admin/generate/jobs/:id', authRequired, async (req, res) => {
|
||||
const job = getJob(req.params.id);
|
||||
if (!job) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
// ---------- events ----------
|
||||
const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_en, year, type,
|
||||
location_fa, location_en, city_fa, city_en, description_fa, description_en,
|
||||
|
|
@ -688,8 +844,10 @@ app.get('/api/admin/radar', authRequired, async (req, res) => {
|
|||
});
|
||||
|
||||
app.get('/api/radar/:id', async (req, res) => {
|
||||
const canSeeDrafts = await hasAdminSession(req);
|
||||
const row = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not_found' });
|
||||
if (!canSeeDrafts && (row.status || 'published') !== 'published') return res.status(404).json({ error: 'not_found' });
|
||||
res.json(rowToRadarItem(row));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue