import { file, write } from "bun"; import { existsSync } from "node:fs"; import path from "node:path"; import * as cheerio from "cheerio"; // ========== TYPES ========== type ArticleEntry = [string, string, string, string, string?]; type ArtikelData = Record; interface PrimaryImageMeta { url: string; contentUrl: string; width: number; height: number; caption: string; encodingFormat?: string; } interface ExtractedMeta { articleUrl: string; headline: string; pageTitle: string; description: string; siteName: string; authorName: string; language: string; datePublished: string; dateModified: string; licenseUrl: string; articleTags: string[]; newsKeywords: string[]; primaryImage: PrimaryImageMeta; articleAuthorProfile: string; articlePublisherProfile: string; twitterCreatorProfile: string; twitterSiteProfile: string; fediverseProfile: string; } // ========== CONFIG ========== const BASE_URL = "https://dalam.web.id"; const SITE_NAME = "Layar Kosong"; const SITE_DESCRIPTION = "Catatan digital tentang teknologi, sejarah, sosial, budaya, dan kehidupan."; const AUTHOR = "Fakhrul Rijal"; const LICENSE_URL = "https://creativecommons.org/licenses/by/4.0/"; const LANGUAGE = "id-ID"; // Default untuk blog. Kalau mau generik, ganti menjadi "Article". const SCHEMA_ARTICLE_TYPE = "BlogPosting"; const LOGO_URL = "https://dalam.web.id/favicon.svg"; const LOGO_WIDTH = 1024; const LOGO_HEIGHT = 1024; const AUTHOR_IMAGE_URL = "https://dalam.web.id/favicon.svg"; const AUTHOR_IMAGE_WIDTH = 512; const AUTHOR_IMAGE_HEIGHT = 512; const AUTHOR_IMAGE_TYPE = "image/svg+xml"; const DEFAULT_IMAGE_WIDTH = 1200; const DEFAULT_IMAGE_HEIGHT = 630; const FORCE_RESCHEMA = process.argv.includes("--force"); const ALLOWED_CATEGORIES = new Set([ "gaya-hidup", "jejak-sejarah", "lainnya", "olah-media", "opini-sosial", "sistem-terbuka", "warta-tekno", ]); const SCHEMA_REGEX = /]*>[\s\S]*?<\/script>\s*/gi; const SIGNATURE_KEY = "schema_oleh_Fakhrul_Rijal"; const STOPWORDS = new Set([ "yang", "untuk", "dengan", "adalah", "dalam", "dari", "pada", "atau", "itu", "dan", "sebuah", "aku", "ke", "saya", "ini", "gue", "gua", "elu", "elo" ]); const PERSON_SAME_AS = [ "https://facebook.com/frijal", "https://github.com/frijal", "https://linkedin.com/in/frijal", "https://x.com/responaja", "https://threads.net/frijal", "https://www.youtube.com/@frijal", "https://www.tiktok.com/@gibah.dilarang", "https://mastodon.social/@frijal", "https://www.reddit.com/user/Fearless_Economics69" ]; const ORG_SAME_AS = [ "https://facebook.com/frijalpage", "https://x.com/responaja", "https://www.youtube.com/@frijal" ]; const YT_ID_REGEXES = [ /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/gi, /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]{11})/gi, /youtube\.com\/watch\?[^"'\s>]*v=([a-zA-Z0-9_-]{11})/gi, /youtu\.be\/([a-zA-Z0-9_-]{11})/gi, ]; // ========== UTILITIES ========== const cleanBaseUrl = BASE_URL.replace(/\/+$/, ""); const siteUrl = cleanBaseUrl; const searchUrlTemplate = `${cleanBaseUrl}/search/?q={search_term_string}`; const slugify = (t: unknown): string => String(t).toString().toLowerCase().trim() .replace(/^[^\w\s]*/u, "") .replace(/ & /g, "-and-") .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); const categoryNameClean = (c: string): string => String(c).trim() .replace(/^[^\w\s]*/u, "") .replace(/-+/g, " ") .replace(/\b\w/g, (m) => m.toUpperCase()); const cleanText = (value: unknown): string => String(value || "") .replace(/\s+/g, " ") .trim(); const normalizeDescription = (text: string): string => cleanText(text).slice(0, 300); const normalizeLocale = (locale: string): string => { const value = cleanText(locale); if (!value) return LANGUAGE; return value.replace("_", "-"); }; const absoluteUrl = ( url: string | undefined | null, fallback = LOGO_URL ): string => { const value = cleanText(url); if (!value) return fallback; if (/^https?:\/\//i.test(value)) return value; if (value.startsWith("/")) { return `${cleanBaseUrl}${value}`; } return `${cleanBaseUrl}/${value.replace(/^\.?\//, "")}`; }; const toInt = (value: unknown, fallback: number): number => { const n = Number.parseInt(String(value || ""), 10); return Number.isFinite(n) && n > 0 ? n : fallback; }; const uniqueClean = (items: string[]): string[] => { const seen = new Set(); const result: string[] = []; for (const item of items) { const value = cleanText(item); if (!value) continue; const key = value.toLowerCase(); if (seen.has(key)) continue; seen.add(key); result.push(value); } return result; }; const splitKeywords = (text: string): string[] => cleanText(text) .split(",") .map(s => cleanText(s)) .filter(Boolean); const safeJsonLd = (data: unknown): string => JSON.stringify(data) .replace(//g, "\\u003E") .replace(/\//g, "\\/"); const stripSchemaSignature = (html: string): string => html.replace( new RegExp(`