diff --git a/README.md b/README.md index f2e5e94..ef5a856 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,14 @@ The preset does not fetch image URLs, inspect a store, validate dynamic Markets/ cover every column dependency, or guarantee that Shopify accepts a file. Shopify supports older headers and dynamic columns, so the tool avoids pretending it can reject every unknown header. +### Exact-error triage + +`shopify-csv-error-triage.html` accepts only one Shopify import error message, matches it against +the current documented error families, and explains whether the next check belongs to the local +file, store state, or an external image/taxonomy resource. The classifier is deterministic and +local-only: it has no upload, store login, fetch, storage, analytics, or unknown-error guess. It is +an entry point into the existing free checker, not a claim that every Shopify error is detectable. + ## Fixed-scope cleanup pilot The completed **CSV Preflight Pro offline batch edition** is offered as a 15,000 sat Personal diff --git a/index.html b/index.html index e256ec8..72757ac 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ CSV Preflight — local CSV and Shopify product import checker -

Read: CSV Is a State Machine · Checkable example · Public CI case study · 한국어 서비스

LOCAL-ONLY CSV CHECK

Catch import failures
before your importer does.

Drop or paste CSV. Nothing leaves your browser.

+

Read: CSV Is a State Machine · Checkable example · Public CI case study · Shopify error triage · 한국어 서비스

LOCAL-ONLY CSV CHECK

Catch import failures
before your importer does.

Drop or paste CSV. Nothing leaves your browser.

OR PASTE
Shopify preset reviewed against the official product CSV guide on 2026-08-27. It checks a bounded set of documented rules and does not guarantee import success.
diff --git a/offer.test.mjs b/offer.test.mjs index 4431601..3f74c8a 100644 --- a/offer.test.mjs +++ b/offer.test.mjs @@ -25,6 +25,7 @@ const batchTemplate = readFileSync(new URL("./.github/ISSUE_TEMPLATE/batch-licen const batchTemplateKo = readFileSync(new URL("./.github/ISSUE_TEMPLATE/batch-license-ko.yml", import.meta.url), "utf8"); const action = readFileSync(new URL("./action.yml", import.meta.url), "utf8"); const shopifyGuideKo = readFileSync(new URL("./shopify-csv-guide-ko.html", import.meta.url), "utf8"); +const shopifyErrorTriage = readFileSync(new URL("./shopify-csv-error-triage.html", import.meta.url), "utf8"); const githubActionGuide = readFileSync(new URL("./validate-csv-github-actions.html", import.meta.url), "utf8"); const ciSetupTerms = readFileSync(new URL("./ci-setup-terms.html", import.meta.url), "utf8"); const ciSetupTermsZhCn = readFileSync(new URL("./ci-setup-terms-zh-CN.html", import.meta.url), "utf8"); @@ -189,9 +190,18 @@ test("publishes a bounded Korean Shopify CSV troubleshooting guide", () => { assert.match(shopifyGuideKo, /닫히지 않은 따옴표/); assert.match(shopifyGuideKo, /가져오기 성공 보장이 아니라/); assert.match(shopifyGuideKo, /help\.shopify\.com\/en\/manual\/products\/import-export\/common-import-issues/); + assert.match(shopifyGuideKo, /shopify-csv-error-triage\.html/); assert.doesNotMatch(shopifyGuideKo, /공식 도구|Shopify 인증|모든 오류/); }); +test("links the exact-error triage entry point without overstating its scope", () => { + assert.match(page, /shopify-csv-error-triage\.html/); + assert.match(shopifyErrorTriage, /Nothing is uploaded, stored, or sent/); + assert.match(shopifyErrorTriage, /Unknown messages stay unknown/); + assert.match(shopifyErrorTriage, /not affiliated with Shopify/); + assert.doesNotMatch(shopifyErrorTriage, /Shopify approved|official checker|guaranteed fix/i); +}); + test("publishes truthful search metadata for every public page", () => { const structuredDataMatch = page.match( / + + diff --git a/shopify-csv-guide-ko.html b/shopify-csv-guide-ko.html index 3895db5..e1dab40 100644 --- a/shopify-csv-guide-ko.html +++ b/shopify-csv-guide-ko.html @@ -16,7 +16,7 @@
-

← CSV Preflight 한국어 서비스 · 무료 검사기

+

← CSV Preflight 한국어 서비스 · 무료 검사기 · 오류 메시지 진단

SHOPIFY PRODUCT CSV PREFLIGHT

Shopify 상품 CSV,
가져오기 전에 볼 5가지

diff --git a/shopify-error-triage-app.mjs b/shopify-error-triage-app.mjs new file mode 100644 index 0000000..c0fb79f --- /dev/null +++ b/shopify-error-triage-app.mjs @@ -0,0 +1,75 @@ +import { classifyShopifyError } from "./shopify-error-triage.mjs"; + +const required = [ + "triage-form", + "error-text", + "triage-result", + "result-status", + "result-title", + "result-cause", + "result-next", + "result-scope", + "result-source", +]; + +const nodes = Object.fromEntries(required.map((id) => [id, document.getElementById(id)])); +const missing = required.filter((id) => !nodes[id]); +if (missing.length) throw new Error(`Shopify error triage is missing required nodes: ${missing.join(", ")}`); + +function setText(node, value) { + node.textContent = value ?? ""; +} + +function render(result) { + nodes["triage-result"].hidden = false; + nodes["result-cause"].hidden = result.status !== "matched"; + nodes["result-scope"].hidden = result.status !== "matched"; + + if (result.status === "empty") { + setText(nodes["result-status"], "INPUT NEEDED"); + setText(nodes["result-title"], "Paste only Shopify's exact error message."); + setText(nodes["result-next"], "Do not paste CSV rows, product data, customer data, credentials, or private links."); + nodes["result-source"].removeAttribute("href"); + setText(nodes["result-source"], "No text was analyzed"); + return; + } + + if (result.status === "too_long") { + setText(nodes["result-status"], "INPUT TOO LONG"); + setText(nodes["result-title"], `Use only the exact error message, up to ${result.maxLength} characters.`); + setText(nodes["result-next"], "Remove surrounding CSV content, email text, store details, and other context before trying again."); + nodes["result-source"].removeAttribute("href"); + setText(nodes["result-source"], "No text was analyzed"); + return; + } + + if (result.status === "unknown") { + setText(nodes["result-status"], "NO DOCUMENTED MATCH"); + setText(nodes["result-title"], result.title); + setText(nodes["result-next"], result.nextStep); + } else { + setText(nodes["result-status"], result.confidence === "official-documented" ? "DOCUMENTED MATCH" : "COMMUNITY-OBSERVED MATCH"); + setText(nodes["result-title"], result.title); + setText(nodes["result-cause"], `Likely cause: ${result.cause}`); + setText(nodes["result-next"], `Next safe step: ${result.nextStep}`); + setText(nodes["result-scope"], `${result.scopeLabel}: ${result.checkerFit}`); + } + + nodes["result-source"].href = result.sourceUrl; + setText(nodes["result-source"], `Source: ${result.sourceLabel}`); +} + +nodes["triage-form"].addEventListener("submit", (event) => { + event.preventDefault(); + try { + render(classifyShopifyError(nodes["error-text"].value)); + } catch { + render({ + status: "unknown", + title: "This message could not be classified locally", + nextStep: "Use Shopify's current troubleshooting page. No input was stored or sent.", + sourceUrl: "https://help.shopify.com/en/manual/products/import-export/common-import-issues", + sourceLabel: "Shopify Help Center", + }); + } +}); diff --git a/shopify-error-triage.mjs b/shopify-error-triage.mjs new file mode 100644 index 0000000..888bc1a --- /dev/null +++ b/shopify-error-triage.mjs @@ -0,0 +1,292 @@ +export const MAX_ERROR_LENGTH = 500; + +const OFFICIAL_ERRORS_URL = "https://help.shopify.com/en/manual/products/import-export/common-import-issues"; +const IMPORT_GUIDE_URL = "https://help.shopify.com/en/manual/products/import-export/import-products"; +const REFERENCE_LIMIT_EXAMPLE_URL = "https://community.shopify.com/t/csv-import-issue/645056"; + +const scopeLabels = Object.freeze({ + "local-file": "Local file", + "store-state": "Store state", + "external-resource": "External resource", +}); + +const rules = [ + { + id: "reference-limit", + patterns: [/cannot add more than 10[,.]?000 references to a file/], + title: "A referenced file reached Shopify's reference limit", + cause: "The message points to a store file or asset reference limit, not the number of CSV rows.", + nextStep: "Inspect the affected row's image or file-reference fields. If one asset is reused broadly, use a fresh asset URL or ask Shopify Support to confirm the store-side count before changing the catalog.", + scope: "store-state", + checkerFit: "A local CSV checker can show repeated URLs, but it cannot read the existing store-side reference count.", + sourceUrl: REFERENCE_LIMIT_EXAMPLE_URL, + sourceLabel: "Current Shopify Community example", + confidence: "community-observed", + }, + { + id: "daily-variant-limit", + patterns: [/daily variant creation limit reached/], + title: "The store reached its daily variant-creation limit", + cause: "Eligible high-variant stores can create only a bounded number of new variants in a 24-hour window.", + nextStep: "Wait for the 24-hour window to reset, then retry a smaller import. Do not reshape otherwise valid rows solely to bypass the limit.", + scope: "store-state", + checkerFit: "The file may be structurally valid; a local checker cannot see the store's rolling variant count.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "fulfillment-service-blank", + patterns: [/fulfillment service can(?:'|’)t be blank/], + title: "Fulfillment service is blank", + cause: "The affected row requires a fulfillment-service value.", + nextStep: "Use the exact service handle configured for the variant, or use manual when no fulfillment service applies.", + scope: "local-file", + checkerFit: "The local Shopify profile can flag a blank or malformed fulfillment value when the relevant column is present.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "handle-exists", + patterns: [/ignored line .* because handle .* already exists/], + title: "The product handle already exists", + cause: "Shopify uses the handle to identify a product, and this import is colliding with an existing handle.", + nextStep: "Decide whether the row should update the existing product or create a new product. Use overwrite only after backing up and reviewing every included column.", + scope: "store-state", + checkerFit: "A local file can reveal duplicate handles inside the file, but it cannot know which handles already exist in the store.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "missing-product-data", + patterns: [/ignored line .* because it did not contain product data/], + title: "A row has a handle but no product title", + cause: "Shopify found a product identifier without the product data required for that row.", + nextStep: "Review the affected row and its neighboring rows. Restore the intended title or remove the accidental handle without guessing other product values.", + scope: "local-file", + checkerFit: "The local Shopify profile can help inspect required headers and values without uploading the catalog.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "illegal-quoting", + patterns: [/illegal quoting(?: on line)?|missing or stray quote(?: on line)?/], + title: "The CSV has invalid quoting or encoding", + cause: "A missing, extra, or smart quote can change where Shopify thinks a field or row ends; non-UTF-8 text can produce the same family of error.", + nextStep: "Save the source as UTF-8, replace smart quotes used as delimiters with straight quotes, and inspect the reported line in a text editor before importing again.", + scope: "local-file", + checkerFit: "The local checker rejects invalid UTF-8 and reports unclosed quoted fields, while preserving every parsed row for review.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "incorrect-header-check", + patterns: [/incorrect header check/], + title: "Shopify associated the header check with an image row", + cause: "Shopify's current troubleshooting page directs merchants to isolate the image on the affected line.", + nextStep: "Temporarily remove the image from that line and retry a small test. If the import then works, host the image at a different public direct URL.", + scope: "external-resource", + checkerFit: "The local checker validates URL shape only; it deliberately does not fetch or inspect remote images.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + confidence: "official-documented", + }, + { + id: "invalid-header", + patterns: [/invalid csv header|missing headers|unexpected token\s* pattern.test(normalized)); + return rule.match === "any" ? results.some(Boolean) : results.every(Boolean); +} + +export function classifyShopifyError(errorText) { + if (typeof errorText !== "string") throw new TypeError("Shopify error text must be a string"); + const trimmed = errorText.trim(); + if (!trimmed) return { status: "empty" }; + if (trimmed.length > MAX_ERROR_LENGTH) return { status: "too_long", maxLength: MAX_ERROR_LENGTH }; + + const normalized = normalizeErrorText(trimmed); + const rule = rules.find((candidate) => matches(candidate, normalized)); + if (!rule) { + return { + status: "unknown", + title: "No documented match found", + nextStep: "Check Shopify's current troubleshooting page or contact Shopify Support. Do not change catalog values based on a guessed cause.", + sourceUrl: OFFICIAL_ERRORS_URL, + sourceLabel: "Shopify Help Center", + }; + } + + return { + status: "matched", + id: rule.id, + title: rule.title, + cause: rule.cause, + nextStep: rule.nextStep, + scope: rule.scope, + scopeLabel: scopeLabels[rule.scope], + checkerFit: rule.checkerFit, + sourceUrl: rule.sourceUrl, + sourceLabel: rule.sourceLabel, + confidence: rule.confidence, + }; +} diff --git a/shopify-error-triage.test.mjs b/shopify-error-triage.test.mjs new file mode 100644 index 0000000..cb72e88 --- /dev/null +++ b/shopify-error-triage.test.mjs @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { classifyShopifyError, MAX_ERROR_LENGTH } from "./shopify-error-triage.mjs"; + +const samples = new Map([ + ["Daily variant creation limit reached, try again", "daily-variant-limit"], + ["Line 2: Validation failed: Cannot add more than 10000 references to a file.", "reference-limit"], + ["Fulfillment service can't be blank", "fulfillment-service-blank"], + ["Ignored line 4-8 because handle `shirt` already exists", "handle-exists"], + ["Ignored line 9 because it did not contain product data", "missing-product-data"], + ["Illegal quoting on line 17", "illegal-quoting"], + ["Incorrect header check", "incorrect-header-check"], + ["Invalid CSV header: missing headers", "invalid-header"], + ["Network error: Unexpected token < in JSON at position 0", "invalid-header"], + ["Inventory policy is not included in the list", "inventory-policy"], + ["Inventory quantity can't be blank", "inventory-quantity-blank"], + ["Inventory quantity is not a number", "inventory-quantity-number"], + ["Missing or stray quote on line 2", "illegal-quoting"], + ["Not a valid product category", "product-category"], + ["Validation failed: An error occurred while trying to download the image", "image-download"], + ["Validation failed: getaddrinfo: nodename nor servname provided, or not known", "image-dns"], + ["Validation failed: options are not unique", "duplicate-options"], + ["Validation failed: price can't be blank", "price-blank"], + ["Validation failed: The uploaded image exceeds the 20 megapixel limit", "image-megapixels"], + ["Validation failed: Value must be a valid product reference", "product-reference"], + ['Import fails with error “Line is invalid (No details)”', "line-invalid-no-details"], +]); + +test("classifies the documented error families without store or file access", () => { + for (const [message, expectedId] of samples) { + const result = classifyShopifyError(message); + assert.equal(result.status, "matched", message); + assert.equal(result.id, expectedId, message); + assert.ok(["local-file", "store-state", "external-resource"].includes(result.scope)); + assert.match(result.sourceUrl, /^https:\/\//); + } +}); + +test("normalizes case, whitespace, smart quotes, and variable line numbers", () => { + assert.equal(classifyShopifyError(" INVENTORY QUANTITY IS NOT A NUMBER ").id, "inventory-quantity-number"); + assert.equal(classifyShopifyError("Fulfillment service can’t be blank").id, "fulfillment-service-blank"); + assert.equal(classifyShopifyError("Ignored line 2-999 because handle shirt already exists").id, "handle-exists"); +}); + +test("returns bounded non-diagnoses for empty, long, and unknown input", () => { + assert.deepEqual(classifyShopifyError(" "), { status: "empty" }); + assert.deepEqual(classifyShopifyError("x".repeat(MAX_ERROR_LENGTH + 1)), { status: "too_long", maxLength: MAX_ERROR_LENGTH }); + const unknown = classifyShopifyError("A brand-new importer message with no documented match"); + assert.equal(unknown.status, "unknown"); + assert.doesNotMatch(JSON.stringify(unknown), /brand-new importer message/); + assert.throws(() => classifyShopifyError(null), TypeError); +}); + +test("publishes a local-only bounded triage page with no data intake or network code", () => { + const page = readFileSync(new URL("./shopify-csv-error-triage.html", import.meta.url), "utf8"); + const app = readFileSync(new URL("./shopify-error-triage-app.mjs", import.meta.url), "utf8"); + assert.match(page, /maxlength="500"/); + assert.match(page, /Nothing is uploaded, stored, or sent/); + assert.match(page, /Do not paste CSV rows, product or customer data/); + assert.match(page, /does not access a store, fetch images/); + assert.match(page, /does not .*guarantee an import/); + assert.match(page, /Unknown messages stay unknown/); + assert.match(page, /10,000-reference message comes from a .*current Shopify Community report/); + assert.match(page, /community-observed rather than official documentation/); + assert.match(page, /template=csv-cleanup\.yml/); + assert.doesNotMatch(page, /]+\saction=|checkout|guarantees? (?:a successful )?import/i); + assert.doesNotMatch(`${page}\n${app}`, /fetch\s*\(|XMLHttpRequest|localStorage|sessionStorage|sendBeacon|WebSocket/); + assert.doesNotMatch(app, /innerHTML|insertAdjacentHTML/); +}); + +test("renders a matched result through the browser entry point", async () => { + class FakeNode { + constructor() { + this.hidden = false; + this.textContent = ""; + this.value = ""; + this.href = ""; + this.listeners = new Map(); + } + addEventListener(type, handler) { this.listeners.set(type, handler); } + removeAttribute(name) { if (name === "href") this.href = ""; } + } + + const ids = [ + "triage-form", "error-text", "triage-result", "result-status", "result-title", + "result-cause", "result-next", "result-scope", "result-source", + ]; + const nodes = new Map(ids.map((id) => [id, new FakeNode()])); + const previousDocument = globalThis.document; + globalThis.document = { getElementById: (id) => nodes.get(id) ?? null }; + try { + await import(`./shopify-error-triage-app.mjs?test=${Date.now()}`); + nodes.get("error-text").value = "Validation failed: options are not unique"; + let prevented = false; + nodes.get("triage-form").listeners.get("submit")({ preventDefault: () => { prevented = true; } }); + assert.equal(prevented, true); + assert.equal(nodes.get("triage-result").hidden, false); + assert.equal(nodes.get("result-status").textContent, "DOCUMENTED MATCH"); + assert.match(nodes.get("result-title").textContent, /option values/i); + assert.match(nodes.get("result-source").href, /^https:\/\/help\.shopify\.com/); + } finally { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); diff --git a/sitemap.xml b/sitemap.xml index 0a09aef..c46e167 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,7 +2,7 @@ https://softpeanut.github.io/csv-preflight/ - 2026-08-27 + 2026-08-28 https://softpeanut.github.io/csv-preflight/ko.html @@ -30,7 +30,11 @@ https://softpeanut.github.io/csv-preflight/shopify-csv-guide-ko.html - 2026-08-27 + 2026-08-28 + + + https://softpeanut.github.io/csv-preflight/shopify-csv-error-triage.html + 2026-08-28 https://softpeanut.github.io/csv-preflight/validate-csv-github-actions.html