Files
buzz-sheet/lib/guides/google-sheet-import.ts
T
2026-09-03 21:39:51 +07:00

1389 lines
44 KiB
TypeScript

import { posix as path } from "node:path";
import { unzipSync } from "fflate";
import sharp from "sharp";
export type SheetMatrix = string[][];
export interface GuideImportSource {
characterName: string;
overview: string;
workbookId: string;
coverId: string;
layout: "full" | "compact";
infoSheet: string;
weaponMetric?: "overall-personal" | "personal-only" | "primary-only";
teamSheets: string[];
comboSheets: string[];
skipWrite?: boolean;
}
export interface ImportCatalogItem {
key: string;
name: string;
imageUrl?: string;
}
export interface ImportCatalogCharacter extends ImportCatalogItem {
constellations: Array<{ level: number; description: string }>;
}
export interface ImportCatalog {
characters: ImportCatalogCharacter[];
weapons: ImportCatalogItem[];
artifacts: ImportCatalogItem[];
}
interface WorkbookVisual {
characterKey?: string;
weaponKey?: string;
artifactKey?: string;
}
type WorkbookVisuals = Map<string, WorkbookVisual>;
export interface GuideImportDraft {
source: GuideImportSource;
characterKey: string;
weapons: Array<{
weaponKey: string;
refinement: number;
overallPercent: number | null;
personalPercent: number | null;
note: string | null;
}>;
artifacts: {
groups: Array<{
name: "Late Game" | "Early Game";
rows: Array<{
firstArtifactKey: string;
secondArtifactKey: string | null;
percent: number;
note: string | null;
}>;
}>;
sands: string[];
goblet: string[];
circlet: string[];
substats: string[];
targets: Array<{ stat: string; value: string }>;
talents: Array<{
type: "NA" | "E" | "Q";
priority: number;
level?: string;
relationToNext?: "gt" | "gte" | "eq";
}>;
note: string | null;
};
constellationGroups: Array<{
note: string | null;
rows: Array<{
level: number;
detail: string;
cumulativePercent: number;
isGreat: boolean;
}>;
}>;
teams: Array<{
title: string;
rotationSeconds: number | null;
combo: string;
members: Array<{
characterKey: string;
weaponKey: string | null;
artifactKey: string | null;
secondArtifactKey: string | null;
constellation: number;
refinement: number;
damage: number;
}>;
}>;
extras: Array<{
title: string;
rows: Array<{
heading: string;
body: string;
percent: number | null;
}>;
}>;
skipped: string[];
}
export const guideImportSources: GuideImportSource[] = [
{
characterName: "Yumemizuki Mizuki",
overview: "[7.1] Full Guide Mizuki",
workbookId: "1bLEKBOTMZ6H3NmxEM7o32HQgNqcRBR0pUV-OIeACpXA",
coverId: "01a0387a-4313-7d9a-b2ff-38e9356ccd47",
layout: "full",
infoSheet: "Mizuki Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "overall-personal",
},
{
characterName: "Odette",
overview: "[7.0] Full Guide Odette",
workbookId: "1I7iuPbigKD6OZ5t4M9-tE5AvP-BMAu7iJB54NilP6-o",
coverId: "01a0040b-0f75-7b20-9a87-e6ec105bd803",
layout: "full",
infoSheet: "Odette Info",
teamSheets: ["Team Calc"],
comboSheets: ["Combo (คอมโบ)"],
weaponMetric: "overall-personal",
},
{
characterName: "Yae Miko",
overview: "[7.0] Full Guide Yae Miko",
workbookId: "1kUIsa7UmNc4WbhgRvL99wr0i2pD1rezcr8nWndRibFc",
coverId: "019f3848-2d0d-7295-9d3c-ae78e73752d4",
layout: "full",
infoSheet: "Yae Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "overall-personal",
},
{
characterName: "Mavuika",
overview: "[6.7] Full Guide Mavuika",
workbookId: "1DQIREJUi0RKqIgTDThCbgkgLrwPCqun6fL1D1TswjiU",
coverId: "019eb0ef-0a48-7109-ad20-1d3d1d0685cc",
layout: "compact",
infoSheet: "Mavuika Info",
teamSheets: ["คอมโบแบบลิง Team Calc"],
comboSheets: ["Combo"],
weaponMetric: "primary-only",
},
{
characterName: "Sandrone",
overview: "[6.7] Full Guide Sandrone",
workbookId: "1YqRlAlO_q5EyL38j4E5Z57qztxUBS5muEChS0qC0E8s",
coverId: "019e78b4-9b68-7a07-b1ce-75aaf020b54e",
layout: "compact",
infoSheet: "Sandrone Info",
teamSheets: ["Team Calc", "ชีต7"],
comboSheets: [],
weaponMetric: "overall-personal",
},
{
characterName: "Nicole",
overview: "[6.6] Nicole Guide",
workbookId: "1vacoQds_seHc-4d-4Nhy2LIwkbwrRaTkn3a1epdWDF8",
coverId: "019e31ca-98ba-792c-bbe4-b35e2ccb685e",
layout: "compact",
infoSheet: "Nicole Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Lohen",
overview: "[6.6] Mini Guide Lohen",
workbookId: "1TH_5ve6i-COCe5thV37Z8ZpXiWWoJdklfsJHG9sNmf4",
coverId: "019d86ab-bfd5-77ad-b15f-1f590724f932",
layout: "compact",
infoSheet: "Lohen Info",
teamSheets: ["Team Calc", "ชีต7"],
comboSheets: [],
weaponMetric: "overall-personal",
},
{
characterName: "Nefer",
overview: "[6.5] Nefer Guide",
workbookId: "1bABpC7CTRRZaJ7EDZnzBcNjTTKSZhZQhR_Ee42I7mW0",
coverId: "019d5413-370e-771a-b402-9d064f517e12",
layout: "compact",
infoSheet: "Nefer Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Linnea",
overview: "[6.5] Linnea Guide",
workbookId: "1GLRUNkJcWvLO6FdMt8wgazE1Yic7-mIryLnXIJczyNc",
coverId: "019d07c6-80eb-7cc1-bc32-275125b0694e",
layout: "compact",
infoSheet: "Linnea Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Varka",
overview: "[6.4] Varka Guide",
workbookId: "1wxN1vfbpSqQagJexiT5K0gvioP8Rg_qOuBr514JlNcQ",
coverId: "019d07c6-8614-7495-a7d7-dddfda786cf3",
layout: "compact",
infoSheet: "Varka Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Zibai",
overview: "[6.3] Zibai Guide",
workbookId: "1LZ0nN6tREKa32whGqHQSPTdM4KTX4CxA6sKY2PgzByA",
coverId: "019d07c6-83d7-7f56-a1f6-8d500deb17bb",
layout: "compact",
infoSheet: "Zibai Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Ineffa",
overview: "[6.3] Ineffa Guide",
workbookId: "1ZvZ76gmczMs_L6gcXLW5TgffU0JjQrCqR42aNPELiFs",
coverId: "019d07c6-8574-7e4b-8b9b-48e3c08be240",
layout: "compact",
infoSheet: "Ineffa Info",
teamSheets: ["Team Calc", "Team Calc No Columbina"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Columbina",
overview: "[6.3] Columbina Guide",
workbookId: "12Q2CBMvGgldysO2IENh6O6TXoVL_iqv0VTSve0QHz98",
coverId: "019d07c6-8792-7923-ac39-b2f16cebc40c",
layout: "compact",
infoSheet: "Columbina Info",
teamSheets: [
"Columbina SubDPSSupport | Nodkr",
"Columbina SubDPSSupport | Other",
"Columbina MainDPS (On-field)",
],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Mona",
overview: "[6.3] Mona ไสยเวทย์ Guide",
workbookId: "1GlQWI7J79G6ezUoOwRWyn__TEdEzsd8oee3RGUYoZZA",
coverId: "019d07c6-7fd4-7156-8551-e25cc335022f",
layout: "compact",
infoSheet: "Mona Info",
teamSheets: ["Mona Support", "Mona On field"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Flins",
overview: "[6.3] Flins Guide",
workbookId: "19exQORI29IDvI29f064cgQgp4jtViY9Co0h-y2A0k9w",
coverId: "019d07c6-8314-779f-bfda-4fe82129b14e",
layout: "compact",
infoSheet: "Flins Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
{
characterName: "Fischl",
overview: "[6.3] Fischl ไสยเวทย์ Guide",
workbookId: "10e1fXcfubIqljb2FFfltM6sAKE6whcHujkSpipW-I7Y",
coverId: "019d07c6-869e-72fd-a48c-3bd21688a8a9",
layout: "compact",
infoSheet: "Fischl Info",
teamSheets: ["Team Calc"],
comboSheets: [],
weaponMetric: "primary-only",
},
];
const itemAliases: Record<string, string> = {
"4vv": "Viridescent Venerer",
vv: "Viridescent Venerer",
ttds: "Thrilling Tales of Dragon Slayers",
redhorn: "Redhorn Stonethresher",
wolfgrave: "Wolf's Gravestone",
wolfgravestone: "Wolf's Gravestone",
theunforge: "The Unforged",
primodialjade: "Primordial Jade Winged-Spear",
primordialjade: "Primordial Jade Winged-Spear",
primordialjader1: "Primordial Jade Winged-Spear",
primodialjadecutter: "Primordial Jade Cutter",
kaguraverity: "Kagura's Verity",
athounsandfloatingdreams: "A Thousand Floating Dreams",
dawingfrost: "Dawning Frost",
stellarwatch: "Starcaller's Watch",
nocturnecurtaincall: "Nocturne's Curtain Call",
thounsandblazingsuns: "A Thousand Blazing Suns",
thousandblazingsun: "A Thousand Blazing Suns",
thousandblazingsuns: "A Thousand Blazing Suns",
mightywolf: "Gest of the Mighty Wolf",
balladofthemightywolf: "Gest of the Mighty Wolf",
daybreakchronicles: "The Daybreak Chronicles",
eternalflow: "Tome of the Eternal Flow",
hunterpath: "Hunter's Path",
nightskyunveiling: "Night of the Sky's Unveiling",
nightoftheskys: "Night of the Sky's Unveiling",
marechaussehunter: "Marechaussee Hunter",
carvedfromrisingwinds: "A Day Carved From Rising Winds",
risingwinds: "A Day Carved From Rising Winds",
desertpavillionchronicle: "Desert Pavilion Chronicle",
aubadeofmorningstar: "Aubade of Morningstar and Moon",
calamity: "Calamity Queller",
beacon: "Beacon of the Reed Sea",
floatingdreams: "A Thousand Floating Dreams",
tidalshadow: "Tidal Shadow",
crimsonwitch: "Crimson Witch of Flames",
marechauseehunter: "Marechaussee Hunter",
noblesse: "Noblesse Oblige",
tenacity: "Tenacity of the Millelith",
gildeddream: "Gilded Dreams",
scarletproof: "Scarlet Proof",
resolution: "Resolution of Sojourner",
instructor: "Instructor",
};
const characterAliases: Record<string, string> = {
mizuki: "Yumemizuki Mizuki",
cryomc: "Traveler Cryo",
anemomc: "Traveler Anemo",
geomc: "Traveler Geo",
dendromc: "Traveler Dendro",
hydromc: "Traveler Hydro",
pyromc: "Traveler Pyro",
electromc: "Traveler Electro",
};
function normalized(value: string): string {
return value
.normalize("NFKD")
.toLowerCase()
.replace(/\([^)]*\)/gu, " ")
.replace(/\br(?:efinement\s*)?[1-5](?:-[1-5])?\b/giu, " ")
.replace(/^\s*[24]\s+/u, "")
.replace(/[^a-z0-9]+/gu, "");
}
function cleanLabel(value: string): string {
return value
.split("\n")[0]
.trim()
.replace(/^\s*[24]\s+/u, "");
}
function resolveItem(
value: string,
items: ImportCatalogItem[],
): ImportCatalogItem | null {
const source = normalized(cleanLabel(value));
const alias = itemAliases[source];
const wanted = normalized(alias ?? source);
const exact = items.find((item) => normalized(item.name) === wanted);
if (exact) return exact;
if (wanted.length < 5) return null;
const prefix = items.filter((item) => {
const candidate = normalized(item.name);
return candidate.startsWith(wanted) || wanted.startsWith(candidate);
});
return prefix.length === 1 ? prefix[0] : null;
}
function resolveCharacter(
value: string,
items: ImportCatalogCharacter[],
): ImportCatalogCharacter | null {
const withoutBuild = cleanLabel(value)
.replace(/\s+C[0-6](?:\s+R[1-5])?.*$/iu, "")
.trim();
const source = normalized(withoutBuild);
const wanted = normalized(characterAliases[source] ?? source);
const exact = items.find((item) => normalized(item.name) === wanted);
if (exact) return exact;
if (wanted.length < 4) return null;
const prefix = items.filter((item) =>
normalized(item.name).startsWith(wanted),
);
return prefix.length === 1 ? prefix[0] : null;
}
function percent(value: string): number | null {
if (/^\s*-\s*%?\s*$/u.test(value)) return 0;
const match = /(-?\d+(?:\.\d+)?)\s*%/u.exec(value.replace(/,/gu, ""));
return match ? Number(match[1]) : null;
}
function numeric(value: string): number | null {
const cleaned = value.replace(/,/gu, "").trim();
if (!/^-?\d+(?:\.\d+)?$/u.test(cleaned)) return null;
return Number(cleaned);
}
function refinement(value: string): number {
const match = /\bR([1-5])(?:-[1-5])?\b/iu.exec(value);
return match ? Number(match[1]) : 1;
}
function noteFromLabel(value: string): string | null {
const lines = value
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines.length > 1 ? lines.slice(1).join("\n") : null;
}
function unique<T>(values: T[]): T[] {
return [...new Set(values)];
}
export function parseCsv(value: string): SheetMatrix {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (quoted) {
if (character === '"' && value[index + 1] === '"') {
field += '"';
index += 1;
} else if (character === '"') quoted = false;
else field += character;
} else if (character === '"') quoted = true;
else if (character === ",") {
row.push(field);
field = "";
} else if (character === "\n") {
row.push(field.replace(/\r$/u, ""));
rows.push(row);
row = [];
field = "";
} else field += character;
}
if (field || row.length) {
row.push(field.replace(/\r$/u, ""));
rows.push(row);
}
return rows;
}
export async function fetchSheetCsv(
source: GuideImportSource,
sheet: string,
range: string,
): Promise<SheetMatrix> {
const url = new URL(
`https://docs.google.com/spreadsheets/d/${source.workbookId}/gviz/tq`,
);
url.searchParams.set("tqx", "out:csv");
url.searchParams.set("headers", "0");
url.searchParams.set("sheet", sheet);
url.searchParams.set("range", range);
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!response.ok)
throw new Error(
`${source.characterName}: ${sheet} returned ${response.status}`,
);
const body = await response.text();
if (/^<!doctype html/iu.test(body.trim()))
throw new Error(`${source.characterName}: ${sheet} did not return CSV`);
return parseCsv(body);
}
const assetFingerprintCache = new Map<string, Promise<Uint8Array | null>>();
async function imageFingerprint(input: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
return new Uint8Array(
await sharp(Buffer.from(input instanceof ArrayBuffer ? new Uint8Array(input) : input))
.flatten({ background: "#000" })
.resize(16, 16, { fit: "fill" })
.raw()
.toBuffer(),
);
}
function catalogFingerprint(item: ImportCatalogItem): Promise<Uint8Array | null> {
if (!item.imageUrl) return Promise.resolve(null);
const cached = assetFingerprintCache.get(item.imageUrl);
if (cached) return cached;
const loading = fetch(item.imageUrl, { signal: AbortSignal.timeout(30_000) })
.then(async (response) =>
response.ok ? imageFingerprint(await response.arrayBuffer()) : null,
)
.catch(() => null);
assetFingerprintCache.set(item.imageUrl, loading);
return loading;
}
function fingerprintDistance(left: Uint8Array, right: Uint8Array): number {
let total = 0;
for (let index = 0; index < left.length; index += 1) {
const difference = left[index] - right[index];
total += difference * difference;
}
return Math.sqrt(total / left.length);
}
async function matchCatalogImage<T extends ImportCatalogItem>(
fingerprint: Uint8Array,
items: T[],
): Promise<T | null> {
const candidates = (
await Promise.all(
items.map(async (item) => ({ item, value: await catalogFingerprint(item) })),
)
)
.filter(
(candidate): candidate is { item: T; value: Uint8Array } =>
candidate.value !== null,
)
.map((candidate) => ({
item: candidate.item,
distance: fingerprintDistance(fingerprint, candidate.value),
}))
.sort((left, right) => left.distance - right.distance);
const best = candidates[0];
const runnerUp = candidates[1];
return best &&
best.distance <= 25 &&
(!runnerUp || runnerUp.distance - best.distance >= 5)
? best.item
: null;
}
function xmlValue(value: string): string {
return value
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
}
function xmlAttributes(tag: string): Record<string, string> {
return Object.fromEntries(
[...tag.matchAll(/([\w:.-]+)="([^"]*)"/gu)].map((match) => [
match[1],
xmlValue(match[2]),
]),
);
}
function archiveText(
files: Record<string, Uint8Array>,
fileName: string,
): string | null {
const value = files[fileName];
return value ? new TextDecoder().decode(value) : null;
}
function relationshipMap(xml: string): Map<string, string> {
return new Map(
[...xml.matchAll(/<Relationship\b[^>]*>/gu)].map((match) => {
const attributes = xmlAttributes(match[0]);
return [attributes.Id, attributes.Target];
}),
);
}
function relatedPath(source: string, target: string): string {
return path.normalize(path.join(path.dirname(source), target));
}
async function fetchWorkbookVisuals(
source: GuideImportSource,
catalog: ImportCatalog,
): Promise<WorkbookVisuals> {
if (
!source.teamSheets.length ||
!catalog.characters.some((item) => item.imageUrl)
)
return new Map();
const response = await fetch(
`https://docs.google.com/spreadsheets/d/${source.workbookId}/export?format=xlsx`,
{ signal: AbortSignal.timeout(60_000) },
);
if (!response.ok)
throw new Error(`${source.characterName}: workbook returned ${response.status}`);
const files = unzipSync(new Uint8Array(await response.arrayBuffer()));
const workbookPath = "xl/workbook.xml";
const workbook = archiveText(files, workbookPath);
const workbookRelationships = archiveText(
files,
"xl/_rels/workbook.xml.rels",
);
if (!workbook || !workbookRelationships) return new Map();
const worksheetById = relationshipMap(workbookRelationships);
const visuals: WorkbookVisuals = new Map();
const imageMatches = new Map<string, Promise<WorkbookVisual>>();
for (const sheetTag of workbook.match(/<sheet\b[^>]*>/gu) ?? []) {
const sheet = xmlAttributes(sheetTag);
if (!source.teamSheets.includes(sheet.name)) continue;
const worksheetTarget = worksheetById.get(sheet["r:id"]);
if (!worksheetTarget) continue;
const worksheetPath = relatedPath(workbookPath, worksheetTarget);
const worksheet = archiveText(files, worksheetPath);
const worksheetRelsPath = path.join(
path.dirname(worksheetPath),
"_rels",
`${path.basename(worksheetPath)}.rels`,
);
const worksheetRelationships = archiveText(files, worksheetRelsPath);
if (!worksheet || !worksheetRelationships) continue;
const worksheetRelationshipMap = relationshipMap(worksheetRelationships);
const drawingId = /<drawing\b[^>]*r:id="([^"]+)"/u.exec(worksheet)?.[1];
const drawingTarget = drawingId
? worksheetRelationshipMap.get(drawingId)
: null;
if (!drawingTarget) continue;
const drawingPath = relatedPath(worksheetPath, drawingTarget);
const drawing = archiveText(files, drawingPath);
const drawingRelsPath = path.join(
path.dirname(drawingPath),
"_rels",
`${path.basename(drawingPath)}.rels`,
);
const drawingRelationships = archiveText(files, drawingRelsPath);
if (!drawing || !drawingRelationships) continue;
const drawingRelationshipMap = relationshipMap(drawingRelationships);
for (const anchorMatch of drawing.matchAll(
/<xdr:(?:one|two)CellAnchor>([\s\S]*?)<\/xdr:(?:one|two)CellAnchor>/gu,
)) {
const anchor = anchorMatch[1];
const column = Number(/<xdr:col>(\d+)<\/xdr:col>/u.exec(anchor)?.[1]);
const row = Number(/<xdr:row>(\d+)<\/xdr:row>/u.exec(anchor)?.[1]);
const imageId = /r:embed="([^"]+)"/u.exec(anchor)?.[1];
const imageTarget = imageId ? drawingRelationshipMap.get(imageId) : null;
if (!Number.isInteger(column) || !Number.isInteger(row) || !imageTarget)
continue;
const imagePath = relatedPath(drawingPath, imageTarget);
const image = files[imagePath];
if (!image) continue;
let matched = imageMatches.get(imagePath);
if (!matched) {
matched = imageFingerprint(image).then(async (fingerprint) => {
const [character, weapon, artifact] = await Promise.all([
matchCatalogImage(fingerprint, catalog.characters),
matchCatalogImage(fingerprint, catalog.weapons),
matchCatalogImage(fingerprint, catalog.artifacts),
]);
return {
...(character ? { characterKey: character.key } : {}),
...(weapon ? { weaponKey: weapon.key } : {}),
...(artifact ? { artifactKey: artifact.key } : {}),
};
});
imageMatches.set(imagePath, matched);
}
visuals.set(`${sheet.name}\0${row}\0${column}`, await matched);
}
}
return visuals;
}
function emptyDraft(
source: GuideImportSource,
characterKey: string,
): GuideImportDraft {
return {
source,
characterKey,
weapons: [],
artifacts: {
groups: [
{ name: "Late Game", rows: [] },
{ name: "Early Game", rows: [] },
],
sands: [],
goblet: [],
circlet: [],
substats: [],
targets: [],
talents: [],
note: null,
},
constellationGroups: [],
teams: [],
extras: [],
skipped: [],
};
}
function addWeapon(
draft: GuideImportDraft,
catalog: ImportCatalog,
label: string,
overall: string,
personal: string,
metric: GuideImportSource["weaponMetric"],
): void {
if (!cleanLabel(label)) return;
const primaryPercent = percent(overall);
if (primaryPercent === null || /ชื่ออาวุธ/u.test(label)) return;
const weapon = resolveItem(label, catalog.weapons);
if (!weapon) {
draft.skipped.push(`weapon not in catalog: ${cleanLabel(label)}`);
return;
}
const secondary = percent(personal);
const overallPercent = metric === "personal-only" ? null : primaryPercent;
const personalPercent =
metric === "personal-only"
? primaryPercent
: metric === "overall-personal"
? secondary
: null;
const notes = [noteFromLabel(label)];
if (metric !== "overall-personal" && secondary !== null)
notes.push(`ค่าคอลัมน์ที่สองจากชีต: ${secondary}%`);
draft.weapons.push({
weaponKey: weapon.key,
refinement: refinement(label),
overallPercent,
personalPercent,
note: notes.filter(Boolean).join("\n") || null,
});
}
function compactWeaponMetric(matrix: SheetMatrix): GuideImportSource["weaponMetric"] {
const weaponHeader = matrix
.slice(0, 3)
.flat()
.find((cell) => /Weapons\s+อาวุธ/iu.test(cell));
if (!/ดาเมจส่วนตัว|personal\s*(?:damage|dmg)/iu.test(weaponHeader ?? ""))
return "primary-only";
return matrix.some(
(row) => percent(row[14] ?? "") !== null || percent(row[20] ?? "") !== null,
)
? "overall-personal"
: "personal-only";
}
function artifactParts(label: string): string[] {
const cleaned = cleanLabel(label);
if (/^(?:2\s*)?EM\s*\+\s*2\s*EM$/iu.test(cleaned))
return ["Gilded Dreams", "Wanderer's Troupe"];
if (/^(?:2\s*)?ATK\s*\+\s*2\s*ATK$/iu.test(cleaned))
return ["Gladiator's Finale", "Shimenawa's Reminiscence"];
if (/^Berserker\s*\+\s*2\s*ATK$/iu.test(cleaned))
return ["Berserker", "Gladiator's Finale"];
return cleaned
.split(/\s*\+\s*/u)
.map((item) => item.trim())
.filter(Boolean);
}
function addArtifact(
draft: GuideImportDraft,
catalog: ImportCatalog,
groupIndex: 0 | 1,
label: string,
metric: string,
): void {
if (!cleanLabel(label)) return;
const value = percent(metric);
if (value === null || /ชื่ออาร์ติแฟกต์/u.test(label)) return;
const parts = artifactParts(label);
if (!parts.length || parts.length > 2) {
draft.skipped.push(
`artifact combination cannot be represented: ${cleanLabel(label)}`,
);
return;
}
const resolved = parts.map((part) => resolveItem(part, catalog.artifacts));
if (resolved.some((item) => !item)) {
draft.skipped.push(`artifact not in catalog: ${cleanLabel(label)}`);
return;
}
draft.artifacts.groups[groupIndex].rows.push({
firstArtifactKey: resolved[0]!.key,
secondArtifactKey: resolved[1]?.key ?? null,
percent: value,
note: noteFromLabel(label),
});
}
const targetAliases: Record<string, string> = {
atk: "ATK",
hp: "HP",
def: "DEF",
em: "EM",
er: "ER",
cr: "CRIT RATE",
cdm: "CRIT DAMAGE",
};
function targetName(value: string): string | null {
const match = /\b(ATK|HP|DEF|EM|ER|CR|CDM)\b/iu.exec(value);
return match ? targetAliases[match[1].toLowerCase()] : null;
}
function mainStat(value: string): string | null {
const text = value.trim();
const map: Array<[RegExp, string]> = [
[/ชำนาญธาตุ|^EM$/iu, "EM"],
[/ฟื้นฟูพลังงาน|^ER$/iu, "ER"],
[/พลังโจมตี|^ATK%$/iu, "ATK%"],
[/พลังชีวิต|^HP%$/iu, "HP%"],
[/พลังป้องกัน|^DEF%$/iu, "DEF%"],
[/อัตราคริ|แรงคริ|CRIT/iu, "CRIT Rate%/CRIT DMG%"],
[/โบนัสการรักษา|Healing/iu, "Healing Bonus%"],
];
return map.find(([pattern]) => pattern.test(text))?.[1] ?? null;
}
function validSlotStat(
slot: "sands" | "goblet" | "circlet",
value: string,
): boolean {
const options = {
sands: ["EM", "ER", "ATK%", "HP%", "DEF%"],
goblet: [
"EM",
"ATK%",
"DEF%",
"HP%",
"Pyro DMG%",
"Hydro DMG%",
"Cryo DMG%",
"Electro DMG%",
"Anemo DMG%",
"Geo DMG%",
"Dendro DMG%",
"Physical DMG%",
],
circlet: [
"HP%",
"ATK%",
"DEF%",
"EM",
"CRIT Rate%/CRIT DMG%",
"Healing Bonus%",
],
} as const;
return (options[slot] as readonly string[]).includes(value);
}
function talentType(value: string): "NA" | "E" | "Q" | null {
if (/การโจมตีปกติ|normal/iu.test(value)) return "NA";
if (/สกิลธาตุ|elemental skill/iu.test(value)) return "E";
if (/ท่าไม้ตาย|elemental burst/iu.test(value)) return "Q";
return null;
}
function talentLevel(value: string): string | undefined {
const match = /(?:LV\.?\s*)?([1-9]|10)/iu.exec(value);
return match?.[1];
}
function parseFullInfo(
matrix: SheetMatrix,
draft: GuideImportDraft,
catalog: ImportCatalog,
): void {
for (const row of matrix) {
for (const [labelColumn, metricStart, metricEnd] of [
[4, 20, 30],
[34, 50, 60],
] as const) {
const metrics = row
.slice(metricStart, metricEnd)
.filter((cell) => percent(cell) !== null);
addWeapon(
draft,
catalog,
row[labelColumn] ?? "",
metrics[0] ?? "",
metrics[1] ?? "",
"overall-personal",
);
}
}
let early = false;
for (const row of matrix) {
if (row.some((cell) => /ต้นเกม/u.test(cell))) early = true;
addArtifact(draft, catalog, early ? 1 : 0, row[4] ?? "", row[16] ?? "");
}
for (const row of matrix) {
const slots = [22, 28, 34] as const;
for (const [index, column] of slots.entries()) {
const value = mainStat(row[column] ?? "");
if (!value) continue;
const key = (["sands", "goblet", "circlet"] as const)[index];
if (validSlotStat(key, value)) draft.artifacts[key].push(value);
}
const target = targetName(row[22] ?? "");
if (target && row[28]?.trim())
draft.artifacts.targets.push({ stat: target, value: row[28].trim() });
}
const allCells = matrix.flat();
const substatCell = allCells.find(
(cell) => />/u.test(cell) && mainStat(cell),
);
if (substatCell)
draft.artifacts.substats = unique(
substatCell
.split(/\s*>\s*/u)
.map(mainStat)
.filter((item): item is string => Boolean(item)),
);
for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) {
for (
let column = 0;
column < (matrix[rowIndex]?.length ?? 0);
column += 1
) {
const type = talentType(matrix[rowIndex][column]);
if (!type) continue;
const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? "");
draft.artifacts.talents.push({
type,
priority: draft.artifacts.talents.length + 1,
...(level ? { level } : {}),
});
}
}
}
function parseCompactInfo(
matrix: SheetMatrix,
draft: GuideImportDraft,
catalog: ImportCatalog,
): void {
const metric = compactWeaponMetric(matrix);
const artifactStart = matrix.findIndex((row) =>
row.some((cell) => /Artifacts\s+อาร์ติแฟกต์/iu.test(cell)),
);
for (const row of matrix.slice(
0,
artifactStart >= 0 ? artifactStart : undefined,
)) {
addWeapon(
draft,
catalog,
row[11] ?? "",
row[13] ?? "",
row[14] ?? "",
metric,
);
addWeapon(
draft,
catalog,
row[17] ?? "",
row[19] ?? "",
row[20] ?? "",
metric,
);
}
if (artifactStart >= 0) {
const extraRows: GuideImportDraft["extras"][number]["rows"] = [];
for (const row of matrix.slice(artifactStart)) {
addArtifact(draft, catalog, 0, row[11] ?? "", row[13] ?? "");
addArtifact(draft, catalog, 1, row[17] ?? "", row[19] ?? "");
for (const [labelColumn, detailColumn] of [
[11, 13],
[17, 19],
] as const) {
const label = cleanLabel(row[labelColumn] ?? "");
const detail = row[detailColumn]?.trim() ?? "";
if (
label &&
detail &&
!/ชื่ออาร์ติแฟกต์/u.test(label) &&
percent(detail) === null
) {
extraRows.push({
heading: label.slice(0, 200),
body: detail.slice(0, 2_000),
percent: null,
});
}
}
}
if (extraRows.length)
draft.extras.push({
title: "คำแนะนำอาร์ติแฟกต์เพิ่มเติม",
rows: extraRows.slice(0, 50),
});
}
const targetGroups: Array<{
descriptor: string;
rows: Array<{ stat: string; value: string }>;
}> = [];
let current: {
descriptor: string;
rows: Array<{ stat: string; value: string }>;
} | null = null;
for (const row of matrix) {
const stat = targetName(row[5] ?? "");
const value = row[6]?.trim();
if (!stat || !value) continue;
if (!current || current.rows.some((item) => item.stat === stat)) {
current = { descriptor: row[7]?.trim() ?? "", rows: [] };
targetGroups.push(current);
}
if (row[7]?.trim()) current.descriptor = row[7].trim();
current.rows.push({ stat, value });
}
const primary =
targetGroups.find((group) => /เลทเกม|AR\s*45/iu.test(group.descriptor)) ??
targetGroups.at(-1);
if (primary) draft.artifacts.targets = primary.rows;
const alternateRows = targetGroups
.filter((group) => group !== primary)
.map((group, index) => ({
heading: group.descriptor || `สแตททางเลือก ${index + 1}`,
body: group.rows.map((item) => `${item.stat}: ${item.value}`).join("\n"),
percent: null,
}));
if (alternateRows.length)
draft.extras.push({ title: "สแตทสำหรับแนวทางอื่น", rows: alternateRows });
for (let rowIndex = 0; rowIndex < matrix.length; rowIndex += 1) {
const row = matrix[rowIndex];
const bestIndex = row.findIndex((cell) =>
/ออฟหลักที่ดีที่สุด/iu.test(cell),
);
if (bestIndex >= 0) {
const values = row
.slice(bestIndex + 1, bestIndex + 5)
.map(mainStat)
.filter((item): item is string => Boolean(item));
if (values[0] && validSlotStat("sands", values[0]))
draft.artifacts.sands.push(values[0]);
if (values[1] && validSlotStat("goblet", values[1]))
draft.artifacts.goblet.push(values[1]);
if (values[2] && validSlotStat("circlet", values[2]))
draft.artifacts.circlet.push(values[2]);
}
const offIndex = row.findIndex((cell) => /ออฟรอง/u.test(cell));
if (offIndex >= 0) {
const value = row.slice(offIndex + 1).find((cell) => />/u.test(cell));
if (value)
draft.artifacts.substats = unique(
value
.split(/\s*>\s*/u)
.map(mainStat)
.filter((item): item is string => Boolean(item)),
);
}
for (let column = 0; column < row.length; column += 1) {
const type = talentType(row[column]);
if (!type) continue;
const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? "");
draft.artifacts.talents.push({
type,
priority: draft.artifacts.talents.length + 1,
...(level ? { level } : {}),
});
}
}
const constellationStart = matrix.findIndex((row) =>
row.some((cell) => /Constellation\s+กลุ่มดาว/iu.test(cell)),
);
const talentStart = matrix.findIndex(
(row, index) =>
index > constellationStart &&
row.some((cell) => /Talent\s+พรสวรรค์/iu.test(cell)),
);
if (constellationStart >= 0) {
const rows: GuideImportDraft["constellationGroups"][number]["rows"] = [];
const character = catalog.characters.find(
(item) => item.key === draft.characterKey,
)!;
for (const row of matrix.slice(
constellationStart + 1,
talentStart > 0 ? talentStart : undefined,
)) {
const levelCell = row.find((cell) => /^C[0-6]$/iu.test(cell.trim()));
const explicitLevel = levelCell
? Number(/\d/u.exec(levelCell)?.[0])
: null;
if (explicitLevel === 0) continue;
const metrics = row
.map(percent)
.filter((item): item is number => item !== null);
if (!metrics.length || rows.length >= 6) continue;
const level = explicitLevel ?? rows.length + 1;
if (level < 1 || level > 6 || rows.some((item) => item.level === level))
continue;
const previous = rows.at(-1)?.cumulativePercent ?? 100;
rows.push({
level,
detail:
character.constellations.find((item) => item.level === level)
?.description || `C${level}`,
cumulativePercent: metrics[0],
isGreat: metrics[0] - previous >= 10,
});
}
if (rows.length)
draft.constellationGroups.push({ note: "C0 = 100%", rows });
}
}
function parseMember(
value: string,
catalog: ImportCatalog,
): { characterKey: string; constellation: number; refinement: number } | null {
const character = resolveCharacter(value, catalog.characters);
if (!character) return null;
return {
characterKey: character.key,
constellation: Number(/\bC([0-6])\b/iu.exec(value)?.[1] ?? 0),
refinement: refinement(value),
};
}
export function charactersFromCombo(
value: string,
catalog: ImportCatalog,
): ImportCatalogCharacter[] {
const found: ImportCatalogCharacter[] = [];
for (const segment of value.split(/\s*>\s*/u)) {
const cleaned = segment.trim();
const matches = catalog.characters.filter((item) => {
const full = item.name.toLowerCase();
const first = full.split(/\s+/u)[0];
const source = cleaned.toLowerCase();
return (
source.startsWith(full) ||
(first.length >= 3 && source.startsWith(first))
);
});
if (
matches.length === 1 &&
!found.some((item) => item.key === matches[0].key)
)
found.push(matches[0]);
}
return found;
}
function parseTeams(
matrix: SheetMatrix,
draft: GuideImportDraft,
catalog: ImportCatalog,
sheetName: string,
): void {
for (let rowIndex = 0; rowIndex < matrix.length - 4; rowIndex += 1) {
const row = matrix[rowIndex];
for (let start = 0; start < row.length; start += 1) {
const labels = [0, 3, 6, 9].map((offset) => row[start + offset] ?? "");
const firstMember = parseMember(labels[0], catalog);
if (!firstMember) continue;
const damageRow = matrix[rowIndex + 1] ?? [];
const damages = [0, 3, 6, 9].map((offset) =>
numeric(damageRow[start + offset] ?? ""),
);
if (damages.some((damage) => damage === null)) continue;
const title =
matrix[rowIndex - 1]?.[start]?.trim() ||
`${draft.source.characterName} Team`;
const rotationRow = matrix
.slice(rowIndex + 2, rowIndex + 5)
.find((candidate) => /TOTAL DPS/iu.test(candidate[start] ?? ""));
let rotation: number | null = rotationRow
? Number(/\(([\d.]+)s\)/iu.exec(rotationRow[start] ?? "")?.[1])
: null;
const rotationIndex = rotationRow ? matrix.indexOf(rotationRow) : -1;
const combo =
rotationIndex >= 0
? (matrix[rotationIndex + 1]?.[start]?.trim() ?? "")
: "";
if ((!Number.isFinite(rotation) || rotation <= 0) && rotationRow) {
const dps = rotationRow
.slice(start, start + 13)
.map(numeric)
.find((item): item is number => item !== null && item > 0);
if (dps)
rotation =
damages
.map((damage) => damage ?? 0)
.reduce((total, damage) => total + damage, 0) / dps;
}
if (!Number.isFinite(rotation) || rotation <= 0) rotation = null;
const explicitMembers = labels
.map((label) => parseMember(label, catalog))
.filter((member): member is NonNullable<typeof member> =>
Boolean(member),
);
const comboCharacters = charactersFromCombo(combo, catalog);
const members = [...explicitMembers];
for (const character of comboCharacters) {
if (!members.some((member) => member.characterKey === character.key)) {
members.push({
characterKey: character.key,
constellation: 0,
refinement: 1,
});
}
}
if (members.length !== 4) {
draft.skipped.push(
`${sheetName}: team ${title} has ${members.length} resolvable members`,
);
continue;
}
draft.teams.push({
title: title.slice(0, 160),
rotationSeconds: rotation,
combo: (combo || "-").slice(0, 2_000),
members: members.map((member, index) => ({
...member,
weaponKey: null,
artifactKey: null,
secondArtifactKey: null,
damage: damages[index]!,
})),
});
start += 9;
}
}
}
function parseDetailedTeamExtra(
matrix: SheetMatrix,
sheetName: string,
): GuideImportDraft["extras"][number] | null {
const rows: GuideImportDraft["extras"][number]["rows"] = [];
for (let rowIndex = 0; rowIndex < matrix.length - 6; rowIndex += 1) {
const header = matrix[rowIndex];
for (let start = 0; start < header.length; start += 1) {
if (!/ชื่อตัวละคร/u.test(header[start] ?? "")) continue;
const title = matrix[rowIndex - 1]?.[start]?.trim();
if (!title) continue;
const lines: string[] = [];
for (
let memberRow = rowIndex + 1;
memberRow <= rowIndex + 4;
memberRow += 1
) {
const row = matrix[memberRow] ?? [];
const character = row[start + 1]?.trim();
const damage = row[start + 2]?.trim();
if (!character || numeric(damage) === null) continue;
const weapon = row[start + 3]?.trim();
const artifact = row[start + 4]?.trim();
lines.push(
`${character}: ${damage}${weapon ? ` | ${weapon}` : ""}${artifact ? ` | ${artifact}` : ""}`,
);
}
if (lines.length === 4)
rows.push({
heading: title.slice(0, 200),
body: lines.join("\n").slice(0, 2_000),
percent: null,
});
}
}
return rows.length
? {
title: `${sheetName} — ข้อมูลทีม`.slice(0, 160),
rows: rows.slice(0, 50),
}
: null;
}
function parseExtra(
matrix: SheetMatrix,
title: string,
): GuideImportDraft["extras"][number] | null {
const rows: GuideImportDraft["extras"][number]["rows"] = [];
for (const row of matrix) {
const values = row.map((cell) => cell.trim()).filter(Boolean);
if (!values.length) continue;
const metricIndex = values.findIndex((cell) => percent(cell) !== null);
const heading = values[0];
if (/^(?:MVs%|DPS%|ความหมาย)$/iu.test(heading)) continue;
const body = values
.filter((_, index) => index !== metricIndex)
.slice(1)
.join(" — ");
rows.push({
heading: heading.slice(0, 200),
body: body.slice(0, 2_000),
percent: metricIndex >= 0 ? percent(values[metricIndex]) : null,
});
if (rows.length === 50) break;
}
return rows.length ? { title: title.slice(0, 160), rows } : null;
}
export async function buildGuideImportDraft(
source: GuideImportSource,
catalog: ImportCatalog,
): Promise<GuideImportDraft> {
const character = resolveCharacter(source.characterName, catalog.characters);
if (!character)
throw new Error(`${source.characterName}: character not in catalog`);
const draft = emptyDraft(source, character.key);
if (source.skipWrite) return draft;
if (source.layout === "full") {
const [weapons, artifacts, constellations] = await Promise.all([
fetchSheetCsv(source, "Weapons (อาวุธ)", "A1:BK80"),
fetchSheetCsv(source, "Artifacts & Stats", "A1:BK80"),
fetchSheetCsv(source, "Constellations (กลุ่มดาว)", "A1:BK40"),
]);
parseFullInfo(weapons, draft, catalog);
const artifactDraft = emptyDraft(source, character.key);
parseFullInfo(artifacts, artifactDraft, catalog);
draft.artifacts = artifactDraft.artifacts;
draft.skipped.push(
...artifactDraft.skipped.filter((item) => item.startsWith("artifact")),
);
const rows: GuideImportDraft["constellationGroups"][number]["rows"] = [];
for (const row of constellations) {
const value = percent(row[1] ?? "");
if (value === null || rows.length >= 6) continue;
const level = rows.length + 1;
const previous = rows.at(-1)?.cumulativePercent ?? 100;
rows.push({
level,
detail:
character.constellations.find((item) => item.level === level)
?.description ||
row[5] ||
`C${level}`,
cumulativePercent: value,
isGreat: value - previous >= 10,
});
}
if (rows.length)
draft.constellationGroups.push({ note: "C0 = 100%", rows });
} else {
const info = await fetchSheetCsv(source, source.infoSheet, "A1:U100");
parseCompactInfo(info, draft, catalog);
}
for (const sheet of source.teamSheets) {
const matrix = await fetchSheetCsv(source, sheet, "A1:AN160");
parseTeams(matrix, draft, catalog, sheet);
const detailed = parseDetailedTeamExtra(matrix, sheet);
if (detailed) draft.extras.push(detailed);
}
for (const sheet of source.comboSheets) {
const matrix = await fetchSheetCsv(source, sheet, "A1:BU100");
const extra = parseExtra(matrix, sheet);
if (extra) draft.extras.push(extra);
}
draft.artifacts.sands = unique(draft.artifacts.sands).slice(0, 3);
draft.artifacts.goblet = unique(draft.artifacts.goblet).slice(0, 3);
draft.artifacts.circlet = unique(draft.artifacts.circlet).slice(0, 3);
draft.artifacts.substats = unique(draft.artifacts.substats);
draft.artifacts.targets = draft.artifacts.targets.filter(
(target, index, rows) =>
rows.findIndex((item) => item.stat === target.stat) === index,
);
draft.artifacts.talents = draft.artifacts.talents
.filter(
(talent, index, rows) =>
rows.findIndex((item) => item.type === talent.type) === index,
)
.slice(0, 3)
.map((talent, index) => ({ ...talent, priority: index + 1 }));
draft.weapons = draft.weapons.slice(0, 100);
draft.teams = draft.teams.slice(0, 30);
draft.extras = draft.extras.slice(0, 20);
return draft;
}