Pakiety tematyczne

This commit is contained in:
dm
2025-12-16 20:10:08 +01:00
parent ff05d0dee9
commit 4f0f171bdc
16 changed files with 1320 additions and 146 deletions

View File

@@ -0,0 +1,347 @@
import path from "node:path";
import fs from "node:fs/promises";
import yaml from "js-yaml";
import { XMLParser } from "fast-xml-parser";
import Database from "better-sqlite3";
const CHANNELS_URL = "https://www.jambox.pl/xml/listakanalow.xml";
const DB_PATH =
process.env.FUZ_DB_PATH ||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
function isAuthorized(request) {
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
if (!expected) return false;
const token = request.headers.get("x-admin-token");
return token === expected;
}
function getDb() {
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
return db;
}
async function fetchXml(url) {
const res = await fetch(url, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) throw new Error(`XML HTTP ${res.status} for ${url}`);
return await res.text();
}
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
trimValues: true,
});
const json = parser.parse(xmlText);
const nodes = json?.xml?.node ?? json?.node ?? [];
return Array.isArray(nodes) ? nodes : [nodes];
}
function toInt(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function parseCsvIds(v) {
const s = String(v ?? "").trim();
if (!s) return [];
return s
.split(",")
.map((x) => toInt(x.trim()))
.filter((n) => n != null);
}
function decodeEntities(input) {
if (!input) return "";
let s = String(input)
.replace(/ /g, " ")
.replace(/–/g, "")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16)),
);
return s;
}
function htmlToMarkdown(input) {
if (!input) return "";
let html = "";
if (typeof input === "string") html = input;
else if (input?.p) {
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
else if (Array.isArray(input.p))
html = input.p.map((p) => `<p>${p}</p>`).join("");
} else html = String(input);
let s = decodeEntities(html);
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const lines = s.split("\n").map((x) => x.trim());
const out = [];
let inList = false;
for (const line of lines) {
if (!line) {
if (!inList) out.push("");
continue;
}
if (line === "__LIST_START__") {
inList = true;
continue;
}
if (line === "__LIST_END__") {
inList = false;
out.push("");
continue;
}
if (inList && line.startsWith("__LI__")) {
out.push(`- ${line.replace("__LI__", "").trim()}`);
continue;
}
out.push(line);
}
return out.join("\n").trim();
}
function extractLogoUrl(node) {
const logo = node?.field_logo_fid;
if (!logo) return null;
// w listakanalow.xml jest zwykle bezpośredni URL
if (typeof logo === "string") {
const s = logo.trim();
if (!s) return null;
const m = s.match(/src="([^"]+)"/);
return m?.[1] ?? s;
}
if (logo?.img?.["@_src"]) return logo.img["@_src"];
return null;
}
async function downloadLogoAsBase64(url) {
try {
const res = await fetch(url);
if (!res.ok) return null;
const ct = res.headers.get("content-type") || "image/png";
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return null;
return `data:${ct};base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
function ensureTidColumn(db) {
const cols = db
.prepare("PRAGMA table_info(jambox_channels)")
.all()
.map((r) => String(r.name || "").toLowerCase());
if (!cols.includes("tid")) {
db.exec("ALTER TABLE jambox_channels ADD COLUMN tid INTEGER;");
}
}
export async function POST({ request }) {
if (!isAuthorized(request)) {
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
const body = await request.json().catch(() => ({}));
const dryRun = body?.dryRun === true;
const YAML_PATH =
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"tv-addons.yaml",
);
const db = getDb();
// tabela + unikalność do ON CONFLICT
db.exec(`
CREATE TABLE IF NOT EXISTS jambox_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nazwa TEXT NOT NULL,
pckg_name TEXT NOT NULL,
image TEXT,
opis TEXT,
pckg_addons INTEGER NOT NULL DEFAULT (0)
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_jambox_channels_name_pkg
ON jambox_channels(nazwa, pckg_name);
`);
// ✅ dorób kolumnę tid (dla addonów)
ensureTidColumn(db);
const delAddons = db.prepare(
`DELETE FROM jambox_channels WHERE pckg_addons = 1`,
);
// ✅ zapisujemy pckg_addons=1 i tid
const upsert = db.prepare(`
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis, pckg_addons, tid)
VALUES (@nazwa, @pckg_name, @image, @opis, 1, @tid)
ON CONFLICT(nazwa, pckg_name) DO UPDATE SET
image = COALESCE(excluded.image, jambox_channels.image),
opis = COALESCE(excluded.opis, jambox_channels.opis),
pckg_addons = 1,
tid = COALESCE(excluded.tid, jambox_channels.tid)
`);
const logoCache = new Map();
const rows = [];
try {
// 1) YAML -> tid dodatku
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
const doc = yaml.load(rawYaml);
if (!doc || !Array.isArray(doc.dodatki)) {
return new Response(
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
{
status: 400,
headers: { "content-type": "application/json; charset=utf-8" },
},
);
}
// tid -> nazwa pakietu (z YAML)
const addonTidToName = new Map();
for (const a of doc.dodatki) {
const tid = toInt(a?.tid);
const name = String(a?.nazwa ?? "").trim();
if (tid != null && name) addonTidToName.set(tid, name);
}
const addonTids = new Set(addonTidToName.keys());
// 2) XML kanałów
const xml = await fetchXml(CHANNELS_URL);
const nodes = parseNodes(xml);
for (const node of nodes) {
const channelName = String(
node?.nazwa_kanalu ?? node?.nazwa ?? "",
).trim();
if (!channelName) continue;
const pkgIds = parseCsvIds(node?.pakiety_id);
if (!pkgIds.length) continue;
// interesują nas tylko te pakiety_id, które są w YAML dodatków (tid)
const matchedAddonTids = pkgIds.filter((tid) => addonTids.has(tid));
if (!matchedAddonTids.length) continue;
const opis = htmlToMarkdown(node?.opis) || null;
const key = channelName.toLowerCase();
let img = logoCache.get(key);
if (img === undefined) {
const logoUrl = extractLogoUrl(node);
img = logoUrl ? await downloadLogoAsBase64(logoUrl) : null;
logoCache.set(key, img);
}
for (const tid of matchedAddonTids) {
const pckgName = addonTidToName.get(tid);
if (!pckgName) continue;
rows.push({
nazwa: channelName,
pckg_name: pckgName,
image: img,
opis,
tid, // ✅ kluczowe: zapis tid do bazy
});
}
}
// 3) REFRESH: usuń wszystkie addonowe i wrzuć nowe
if (!dryRun) {
const trx = db.transaction((data) => {
const info = delAddons.run();
for (const r of data) upsert.run(r);
return info.changes;
});
const deleted = trx(rows);
return new Response(
JSON.stringify({
ok: true,
dryRun,
deleted_addon_rows: deleted,
inserted_rows: rows.length,
db: DB_PATH,
yaml: YAML_PATH,
uniqueAddonsUsed: addonTidToName.size,
tidsUsed: addonTids.size,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}
return new Response(
JSON.stringify({
ok: true,
dryRun: true,
would_delete_addon_rows: "(unknown in dryRun)",
would_insert_rows: rows.length,
db: DB_PATH,
yaml: YAML_PATH,
uniqueAddonsUsed: addonTidToName.size,
tidsUsed: addonTids.size,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
} catch (e) {
console.error("import addon jambox_channels:", e);
return new Response(
JSON.stringify({ ok: false, error: String(e?.message || e) }),
{
status: 500,
headers: { "content-type": "application/json; charset=utf-8" },
},
);
} finally {
try {
db.close();
} catch {}
}
}

View File

@@ -0,0 +1,291 @@
import path from "node:path";
import fs from "node:fs/promises";
import yaml from "js-yaml";
import { XMLParser } from "fast-xml-parser";
const PLUS_THEMATIC_URL =
"https://www.jambox.pl/xml/slownik-pakietytematyczneplus.xml";
const PREMIUM_URL = "https://www.jambox.pl/xml/slownik-pakietypremium.xml";
/**
* .env: JAMBOX_ADMIN_TOKEN="..."
*/
function isAuthorized(request) {
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
if (!expected) return false;
const token = request.headers.get("x-admin-token");
return token === expected;
}
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Błąd pobierania XML: ${res.status} ${res.statusText} (${url})`,
);
}
return await res.text();
}
/**
* XML:
* <xml>
* <node><name>...</name><tid>...</tid></node>
* <node>...</node>
* </xml>
*/
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
const root = json.xml ?? json;
let nodes = root?.node ?? [];
if (!nodes) return [];
if (!Array.isArray(nodes)) nodes = [nodes];
return nodes;
}
function toInt(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function normalizeName(name) {
let s = String(name || "").trim();
// często XML ma "Pakiet ..." a u Ciebie w YAML jest bez "Pakiet"
s = s.replace(/^pakiet\s+/i, "");
// "+Max" -> "+ Max"
s = s.replace(/\+([A-Za-zĄĆĘŁŃÓŚŹŻ])/g, "+ $1");
// pojedyncze spacje
s = s.replace(/\s+/g, " ").trim();
return s;
}
function stripDiacritics(s) {
return String(s || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
function slugifyId(name) {
const s = stripDiacritics(String(name || "").toLowerCase())
.replace(/\+/g, " plus ")
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return s || "pakiet";
}
function buildXmlMap(nodesA, nodesB) {
const byTid = new Map();
const byNormName = new Map();
function add(nodes, source) {
for (const n of nodes || []) {
const tid = toInt(n?.tid);
const rawName = String(n?.name ?? "").trim();
const name = normalizeName(rawName);
if (!tid || !name) continue;
if (!byTid.has(tid)) byTid.set(tid, { tid, name, rawName, source });
const key = stripDiacritics(name).toLowerCase();
if (!byNormName.has(key)) byNormName.set(key, { tid, name, rawName, source });
}
}
add(nodesA, "thematic_plus");
add(nodesB, "premium");
return { byTid, byNormName };
}
export async function POST({ request }) {
try {
if (!isAuthorized(request)) {
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json().catch(() => ({}));
const dryRun = body?.dryRun === true;
// domyślnie: tylko aktualizacja (bez dopisywania nowych)
const addMissing = body?.addMissing === true;
const updateNames = body?.updateNames !== false; // default true
const YAML_PATH =
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"tv-addons.yaml",
);
// 1) XML
const [xmlA, xmlB] = await Promise.all([
fetchXml(PLUS_THEMATIC_URL),
fetchXml(PREMIUM_URL),
]);
const nodesA = parseNodes(xmlA);
const nodesB = parseNodes(xmlB);
const { byTid, byNormName } = buildXmlMap(nodesA, nodesB);
// 2) YAML
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
const doc = yaml.load(rawYaml);
if (!doc || !Array.isArray(doc.dodatki)) {
return new Response(
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const changedItems = [];
const unchangedItems = [];
const skippedItems = [];
const addedItems = [];
// indeksy istniejących (dla addMissing)
const existingByTid = new Map();
const existingByNormName = new Map();
for (const a of doc.dodatki) {
const tid = toInt(a?.tid);
const nm = normalizeName(String(a?.nazwa ?? ""));
if (tid != null) existingByTid.set(tid, a);
if (nm) existingByNormName.set(stripDiacritics(nm).toLowerCase(), a);
}
// 2a) aktualizacje
for (const addon of doc.dodatki) {
const id = addon?.id ?? null;
const before = {
tid: toInt(addon?.tid),
nazwa: String(addon?.nazwa ?? ""),
};
let match = null;
// najpierw po tid (najpewniejsze)
if (before.tid != null && byTid.has(before.tid)) {
match = byTid.get(before.tid);
} else {
// potem po nazwie
const key = stripDiacritics(normalizeName(before.nazwa)).toLowerCase();
if (key && byNormName.has(key)) match = byNormName.get(key);
}
if (!match) {
skippedItems.push({ id, reason: "brak dopasowania w XML", before });
continue;
}
const after = {
tid: match.tid,
nazwa: updateNames ? match.name : before.nazwa,
};
const willChange =
before.tid !== after.tid || before.nazwa !== after.nazwa;
if (!willChange) {
unchangedItems.push({ id, tid: before.tid, nazwa: before.nazwa });
continue;
}
if (!dryRun) {
addon.tid = after.tid;
if (updateNames) addon.nazwa = after.nazwa;
}
changedItems.push({
id,
before,
after,
matchedFrom: match.source,
});
}
// 2b) opcjonalnie dopisz brakujące pakiety z XML do YAML
if (addMissing) {
for (const [tid, it] of byTid.entries()) {
if (existingByTid.has(tid)) continue;
const key = stripDiacritics(it.name).toLowerCase();
if (existingByNormName.has(key)) continue;
const newId = slugifyId(it.name);
const newItem = {
id: newId,
nazwa: it.name,
tid: tid,
typ: "checkbox",
opis: "",
cena: [],
};
if (!dryRun) doc.dodatki.push(newItem);
addedItems.push({ id: newId, tid, nazwa: it.name, source: it.source });
}
}
if (!dryRun && (changedItems.length > 0 || addedItems.length > 0)) {
const newYaml = yaml.dump(doc, {
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(YAML_PATH, newYaml, "utf8");
}
return new Response(
JSON.stringify({
ok: true,
dryRun,
yamlPath: YAML_PATH,
options: { addMissing, updateNames },
xmlCounts: {
thematic_plus: nodesA.length,
premium: nodesB.length,
uniqueByTid: byTid.size,
},
changed: changedItems.length,
added: addedItems.length,
unchanged: unchangedItems.length,
skipped: skippedItems.length,
changedItems,
addedItems,
skippedItems,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (err) {
console.error("update-tv-addons error:", err);
return new Response(
JSON.stringify({ ok: false, error: String(err?.message ?? err) }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
}

View File

@@ -23,6 +23,18 @@ function slugifyPkg(name) {
.replace(/(^-|-$)/g, "");
}
function uniqByKey(list, keyFn) {
const seen = new Set();
const out = [];
for (const it of list || []) {
const k = keyFn(it);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(it);
}
return out;
}
export function GET({ url }) {
const q = (url.searchParams.get("q") || "").trim();
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
@@ -44,10 +56,25 @@ export function GET({ url }) {
.prepare(
`
SELECT
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
GROUP_CONCAT(pckg_name, '||') AS packages_blob
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
-- ✅ pakiety główne (pckg_addons = 0)
GROUP_CONCAT(
CASE WHEN IFNULL(pckg_addons, 0) = 0 THEN pckg_name END,
'||'
) AS packages_blob,
-- ✅ pakiety tematyczne (pckg_addons = 1) + tid
GROUP_CONCAT(
CASE
WHEN IFNULL(pckg_addons, 0) = 1 AND tid IS NOT NULL
THEN CAST(tid AS TEXT) || '::' || pckg_name
END,
'||'
) AS thematic_blob
FROM jambox_channels
WHERE nazwa LIKE ? ESCAPE '\\'
GROUP BY nazwa
@@ -58,23 +85,40 @@ export function GET({ url }) {
.all(like, limit);
const data = rows.map((r) => {
// ===== główne =====
const pkgsRaw = String(r.packages_blob || "")
.split("||")
.map((x) => x.trim())
.filter(Boolean);
const packages = uniq(pkgsRaw)
.map((p) => ({
id: slugifyPkg(p),
name: p,
}))
.map((p) => ({ id: slugifyPkg(p), name: p }))
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
// ===== tematyczne =====
const thematicRaw = String(r.thematic_blob || "")
.split("||")
.map((x) => x.trim())
.filter(Boolean);
const thematic_packages = uniqByKey(
thematicRaw
.map((x) => {
const [tid, ...rest] = x.split("::");
const name = rest.join("::").trim();
const t = String(tid || "").trim();
return t && name ? { tid: t, name } : null;
})
.filter(Boolean),
(p) => `${p.tid}::${p.name}`
).sort((a, b) => a.name.localeCompare(b.name, "pl"));
return {
name: r.name,
logo_url: r.logo_url || "",
description: r.description || "",
packages,
thematic_packages, // ✅ NOWE
};
});

View File

@@ -0,0 +1,90 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import yaml from "js-yaml";
import fs from "node:fs";
import Markdown from "../../islands/Markdown.jsx";
import AddonChannelsGrid from "../../islands/jambox/AddonChannelsModal.jsx";
import "../../styles/jambox-tematyczne.css";
/** Typy minimalne */
type AddonPriceRow = {
pakiety?: string[] | any;
"12m"?: number | string;
bezterminowo?: number | string;
};
type TvAddon = {
id?: string;
nazwa?: string;
tid?: number;
typ?: string;
opis?: string;
image?: string;
cena?: AddonPriceRow[];
};
type TvAddonsDoc = {
tytul?: string;
opis?: string;
cena_opis?: string;
dodatki?: TvAddon[];
};
const doc = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/tv-addons.yaml", "utf8"),
) as TvAddonsDoc;
const pageTitle = doc?.tytul ?? "Dodatkowe pakiety TV";
const pageDesc = doc?.opis ?? "";
const addons: TvAddon[] = Array.isArray(doc?.dodatki) ? doc.dodatki : [];
---
<DefaultLayout title={pageTitle} description={pageDesc}>
<section class="f-section">
<div class="f-section-grid-single">
<h1 class="f-section-title">{pageTitle}</h1>
{pageDesc && <Markdown text={pageDesc} />}
</div>
</section>
{addons.map((addon: TvAddon, index: number) => {
const isAboveFold = index === 0;
const hasYamlImage = !!String(addon?.image ?? "").trim();
const pkgName = String(addon?.nazwa ?? "").trim();
const assumeHasMedia = pkgName ? true : hasYamlImage;
const anchorId = addon?.tid != null ? String(addon.tid) : undefined;
return (
<section class="f-section">
<div
class={`f-section-grid f-addon-grid f-addon-section ${
assumeHasMedia ? "md:grid-cols-2" : "md:grid-cols-1"
}`}
data-addon-section
data-has-media={assumeHasMedia ? "1" : "0"}
>
{/* TEKST — lewa, od góry */}
<div class="f-addon-text" id={anchorId}>
{pkgName && <h2 class="f-section-title">{pkgName}</h2>}
{addon?.opis && <Markdown text={addon.opis} />}
</div>
{/* MEDIA — prawa */}
<div class="f-addon-media">
{pkgName ? (
<AddonChannelsGrid
client:idle
packageName={pkgName}
fallbackImage={String(addon?.image ?? "")}
aboveFold={isAboveFold}
title={pkgName}
/>
) : null}
</div>
</div>
</section>
);
})}
</DefaultLayout>