@readystack/gpsr-listing-lint 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,16 @@
1
+ GPSR Listing Lint — EU Product Feed Audit — Licence
2
+
3
+ Free scope
4
+ Scan the open product feed and see every listing row that is missing a GPSR Article 19 field, with the line number and the exact article it fails. is free for any use, personal or commercial, for as long as you keep the
5
+ extension installed, and the online check at the product page is free without registration.
6
+
7
+ Paid scope
8
+ Export a dated evidence pack for the whole workspace catalogue — every feed file, every failing row, per-rule counts — as CSV and Markdown you can hand to a marketplace, importer or auditor. is the paid part. It asks for a licence key issued at purchase (one seat, one key;
9
+ 7-day full refund, no questions). The key is checked against the payment provider and
10
+ cached locally for 30 days so it keeps working offline.
11
+
12
+ Redistribution
13
+ You may not resell, re-host or bundle this extension or its rule set. The rule set and the
14
+ engine are provided as-is; check results are advisory and do not constitute legal advice.
15
+
16
+ (c) ReadyStack — https://getreadystack.com
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # GPSR Listing Lint
2
+
3
+ ![GPSR Listing Lint](https://getreadystack.com/img/promo/sku57790_result_card.jpg)
4
+
5
+ Audits a product feed row by row against Article 19 of the EU General Product Safety Regulation
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npx @readystack/gpsr-listing-lint file
11
+ ```
12
+
13
+ Node 18+. The same 12 rules as the VS Code extension, from a terminal or CI.
14
+
15
+ ## Free
16
+
17
+ - Open any product feed file and see every listing row that is missing a GPSR Article 19 field, with its line number and the exact article it fails — no key, nothing uploaded.
18
+ - `--rules` lists every rule
19
+
20
+ ## With a licence ($29 once)
21
+
22
+ - Export a dated evidence pack for the whole workspace catalogue — every feed file, every failing row, per-rule counts — as CSV and Markdown you can hand to a marketplace, importer or auditor.
23
+
24
+ ```
25
+ @readystack/gpsr-listing-lint --dir ./templates --report html --out report.html
26
+ ```
27
+
28
+ An EU Responsible Person / authorised-representative service is sold as a monthly subscription, per brand; hand-checking a feed at ten seconds a row is about fourteen hours for five thousand rows. This is $29 once.
29
+
30
+ ## Use from an AI agent (MCP)
31
+
32
+ Claude Code · Cursor · Windsurf · any MCP client - add to your MCP config:
33
+
34
+ ```json
35
+ { "mcpServers": { "gpsr-listing-lint": { "command": "npx", "args": ["-y", "@readystack/gpsr-listing-lint", "--mcp"] } } }
36
+ ```
37
+
38
+ Tools: `check_text` and `check_file` (free) · `check_dir` (licence; the full sweep is free for 7 days). The agent gets every finding with the line number.
39
+
40
+ ## Use in CI
41
+
42
+ ```yaml
43
+ - name: GPSR Listing Lint
44
+ run: npx -y @readystack/gpsr-listing-lint --dir . --ci
45
+ ```
46
+
47
+ (container: `docker run --rm -v "$PWD:/work" getreadystack/gpsr-listing-lint --dir /work --ci`)
48
+
49
+ Try the full run free for 7 days — no key needed. Then one licence, 7-day refund, no questions. Set `READYSTACK_LICENSE=<key>` or run `--license <key>` once.
50
+
51
+ [Get a licence](https://buy.polar.sh/polar_cl_MeEOXqXAf3FoF5aXVJjeeyaoJJNIODsmsm5CK0skgvG)
52
+
53
+
54
+ <!-- gpsr listing lint -->
package/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — the same rules as the VS Code extension, run from a terminal or a container. Generated by adapters.py.
3
+ 'use strict';
4
+ const fs = require('fs'), path = require('path');
5
+ const RULES = require('./rules.json');
6
+ const S = require('./strings.json');
7
+ const lic = require('./license.js');
8
+ const ENGINE = require('./engine.js'); // s144 — scaffold products: the same engine the VS Code extension runs
9
+ const RULE_LIST = Array.isArray(ENGINE.RULES) ? ENGINE.RULES : (Array.isArray(RULES) ? RULES : []);
10
+ const RULE_N = ENGINE.RULE_COUNT || RULE_LIST.length;
11
+ function scan(text, file) {
12
+ const r = ENGINE.engine.check(String(text), { today: new Date().toISOString().slice(0, 10), path: file || '' });
13
+ return (r && r.findings || []).map((f) => ({ line: parseInt(f.line, 10) || 1, msg: String(f.msg || f.message || f.check || ''), fix: f.fix || null, sev: (String(f.sev || 'error').toLowerCase().startsWith('err') ? 'error' : 'warn') }));
14
+ }
15
+ function isText(p) { try { const b = fs.readFileSync(p); if (b.length > 2 * 1024 * 1024) return false; const s = b.subarray(0, 4096); for (const x of s) if (x === 0) return false; return true; } catch (e) { return false; } }
16
+ function walk(dir, exts, out) {
17
+ let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
18
+ for (const e of ents) {
19
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.')) continue;
20
+ const p = path.join(dir, e.name);
21
+ if (e.isDirectory()) walk(p, exts, out);
22
+ else if ((!exts.length || exts.includes(path.extname(e.name).toLowerCase())) && isText(p)) out.push(p);
23
+ }
24
+ return out;
25
+ }
26
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
27
+ function csvq(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; }
28
+ function render(rows, fmt) {
29
+ if (fmt === 'json') return JSON.stringify({ tool: S.name, rules: RULE_N, files: rows }, null, 1);
30
+ if (fmt === 'csv') { const o = ['file,line,severity,message,fix']; for (const r of rows) for (const h of r.hits) o.push([csvq(r.file), h.line, h.sev, csvq(h.msg), csvq(h.fix)].join(',')); return o.join('\n') + '\n'; }
31
+ if (fmt === 'html') {
32
+ const n = rows.reduce((a, r) => a + r.hits.length, 0);
33
+ let o = '<!doctype html><meta charset="utf-8"><title>' + esc(S.name) + ' report</title><style>body{font:14px system-ui;margin:24px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:4px 8px;font-size:13px}code{font-family:ui-monospace,monospace}</style>';
34
+ o += '<h1>' + esc(S.name) + '</h1><p>' + rows.length + ' files · ' + n + ' findings · ' + RULE_N + ' rules</p><table><tr><th>file</th><th>line</th><th>sev</th><th>message</th><th>fix</th></tr>';
35
+ for (const r of rows) for (const h of r.hits) o += '<tr><td><code>' + esc(r.file) + '</code></td><td>' + h.line + '</td><td>' + esc(h.sev) + '</td><td>' + esc(h.msg) + '</td><td>' + esc(h.fix || '') + '</td></tr>';
36
+ return o + '</table>';
37
+ }
38
+ let o = ''; for (const r of rows) { o += r.file + '\n'; for (const h of r.hits) o += ' ' + String(h.line).padStart(5) + ' ' + h.sev.padEnd(5) + ' ' + h.msg + (h.fix ? '\n -> ' + h.fix : '') + '\n'; if (!r.hits.length) o += ' (no findings)\n'; }
39
+ return o;
40
+ }
41
+ function trialState() {
42
+ if (process.env.READYSTACK_NO_TRIAL) return { active: false };
43
+ try {
44
+ const p = path.join(path.dirname(lic.storePath()), S.bin + '.trial.json');
45
+ let t = null; try { t = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) {}
46
+ if (!t || !t.until) { t = { until: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString().slice(0, 10) }; fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(t)); }
47
+ return { active: new Date(t.until + 'T23:59:59Z').getTime() > Date.now(), until: t.until };
48
+ } catch (e) { return { active: false }; }
49
+ }
50
+ function mcpServe() {
51
+ // s144 — MCP server over stdio (newline-delimited JSON-RPC · no dependencies). Free: check_text · check_file. Licence (7-day trial): check_dir.
52
+ const rl = require('readline').createInterface({ input: process.stdin });
53
+ const send = (o) => process.stdout.write(JSON.stringify(o) + '\n');
54
+ const tools = [
55
+ { name: 'check_text', description: S.name + ' - run all ' + RULE_N + ' checks on a text (free)', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'file contents' }, path: { type: 'string', description: 'optional file name for context' } }, required: ['text'] } },
56
+ { name: 'check_file', description: S.name + ' - run all checks on one file by path (free)', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
57
+ { name: 'check_dir', description: S.name + ' - sweep a folder and return every finding (licence; the full run is free for 7 days)', inputSchema: { type: 'object', properties: { dir: { type: 'string' }, ext: { type: 'string', description: 'optional extension filter, e.g. .html' } }, required: ['dir'] } }
58
+ ];
59
+ const result = (id, rows) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: render(rows, 'text') }], structuredContent: { tool: S.name, rules: RULE_N, files: rows } } });
60
+ const fail = (id, text) => send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }], isError: true } });
61
+ rl.on('line', async (line) => {
62
+ let m; try { m = JSON.parse(line); } catch (e) { return; }
63
+ const id = m.id, method = m.method;
64
+ if (method === 'initialize') return send({ jsonrpc: '2.0', id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: '@readystack/' + S.bin, version: '1.0.0' } } });
65
+ if (method === 'notifications/initialized' || method === 'ping') { if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} }); return; }
66
+ if (method === 'tools/list') return send({ jsonrpc: '2.0', id, result: { tools } });
67
+ if (method === 'tools/call') {
68
+ const name = (m.params || {}).name, args = (m.params || {}).arguments || {};
69
+ try {
70
+ if (name === 'check_text') return result(id, [{ file: args.path || '(text)', hits: scan(String(args.text || ''), args.path || '') }]);
71
+ if (name === 'check_file') return result(id, [{ file: args.path, hits: scan(fs.readFileSync(args.path, 'utf8'), args.path) }]);
72
+ if (name === 'check_dir') {
73
+ const r = await lic.ensure();
74
+ if (!r.ok && !trialState().active) return fail(id, S.need_key + ' Get a licence ($' + S.price + ', once, 7-day refund): ' + lic.BUY_URL);
75
+ const files = walk(args.dir, args.ext ? [args.ext] : (S.exts || []), []);
76
+ return result(id, files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t, f) }; }));
77
+ }
78
+ return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool ' + name } });
79
+ } catch (e) { return fail(id, String(e && e.message || e)); }
80
+ }
81
+ if (id !== undefined) send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
82
+ });
83
+ }
84
+ function help() {
85
+ return [S.name + ' - ' + S.subtitle, '', 'Usage: ' + S.bin + ' <file> [more files] check the files you name (free, every rule)',
86
+ ' ' + S.bin + ' --dir <folder> [--ext .html] scan a whole folder (licence)',
87
+ ' ' + S.bin + ' ... --report csv|json|html [--out file] export a report (licence)',
88
+ ' ' + S.bin + ' ... --ci exit 1 when an error-level finding exists (licence)',
89
+ ' ' + S.bin + ' --license <key> store your licence key (or set READYSTACK_LICENSE)',
90
+ ' ' + S.bin + ' --rules list the ' + RULE_N + ' rules',
91
+ ' ' + S.bin + ' --mcp run as an MCP server (stdio) for Claude Code / Cursor / Windsurf - free checks, folder sweep needs a licence', '',
92
+ 'Free: ' + S.free, 'Licence ($' + S.price + ', once, 7-day refund): ' + S.paid, 'Get a licence: ' + lic.BUY_URL, ''].join('\n');
93
+ }
94
+ (async function main() {
95
+ try { const _feed = await lic.pullFeed(); if (_feed && Array.isArray(_feed.rules)) { if (Array.isArray(ENGINE.RULES)) for (const r of _feed.rules) ENGINE.RULES.push(r); } } catch (e) {} // ★s134 구독 피드 병합 (키 있는 손님만)
96
+ const a = process.argv.slice(2);
97
+ const get = (k) => { const i = a.indexOf(k); return i >= 0 ? a[i + 1] : null; };
98
+ if (a.includes('--mcp')) { mcpServe(); return; }
99
+ if (!a.length || a.includes('--help') || a.includes('-h')) { process.stdout.write(help()); return; }
100
+ if (a.includes('--rules')) { process.stdout.write((RULE_LIST.length ? RULE_LIST.map((r, i) => String(i + 1).padStart(3) + ' [' + (r.sev || 'warn') + '] ' + (r.message || r.msg || r.id || '')) : Array.from({ length: RULE_N }, (_, i) => String(i + 1).padStart(3) + ' [engine] check ' + (i + 1))).join('\n') + '\n'); return; }
101
+ if (a.includes('--license')) { const r = await lic.ensure(get('--license')); process.stdout.write(r.ok ? 'Licence stored: ' + lic.storePath() + '\n' : 'Licence not accepted (' + r.why + '). Get one: ' + lic.BUY_URL + '\n'); process.exit(r.ok ? 0 : 2); }
102
+ const dir = get('--dir'), fmt = get('--report'), out = get('--out'), ci = a.includes('--ci');
103
+ const exts = a.includes('--ext') ? [get('--ext')] : (S.exts || []);
104
+ const paid = !!(dir || fmt || ci);
105
+ if (paid) {
106
+ const r = await lic.ensure();
107
+ if (!r.ok) {
108
+ const t = trialState(); // s144 reverse trial: the full run is free for 7 days from the first paid use, then the key
109
+ if (t.active) process.stderr.write('Trial: the full run is free until ' + t.until + ' — after that $' + S.price + ' once (7-day refund). Get a licence: ' + lic.BUY_URL + '\n');
110
+ else { process.stderr.write(S.need_key + '\n set READYSTACK_LICENSE=<key> or ' + S.bin + ' --license <key>\n Get a licence ($' + S.price + ', once): ' + lic.BUY_URL + '\n'); process.exit(2); }
111
+ }
112
+ }
113
+ const files = dir ? walk(dir, exts, []) : a.filter((x, i) => !x.startsWith('--') && !['--dir', '--report', '--out', '--ext', '--license'].includes(a[i - 1]));
114
+ if (!files.length) { process.stderr.write('No files. ' + S.bin + ' --help\n'); process.exit(2); }
115
+ const rows = files.map((f) => { let t = ''; try { t = fs.readFileSync(f, 'utf8'); } catch (e) { return { file: f, hits: [], error: String(e.message) }; } return { file: f, hits: scan(t) }; });
116
+ const text = render(rows, fmt || 'text');
117
+ if (out) fs.writeFileSync(out, text); else process.stdout.write(text.endsWith('\n') ? text : text + '\n');
118
+ const errors = rows.reduce((n, r) => n + r.hits.filter((h) => h.sev === 'error').length, 0);
119
+ if (ci && errors) process.exit(1);
120
+ })().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(3); });
package/engine.js ADDED
@@ -0,0 +1,231 @@
1
+ // GPSR Listing Lint — engine. Same file runs in Node (extension) and in the browser (free web page).
2
+ var RULES = (typeof module !== 'undefined') ? require('./rules.json') : window.GPSR_RULES;
3
+
4
+ var RULE_BY_ID = {};
5
+ RULES.forEach(function (r) { RULE_BY_ID[r.id] = r; });
6
+
7
+ // ---- column vocabulary -------------------------------------------------
8
+ var COLS = {
9
+ mfr_name: ['manufacturer', 'manufacturername', 'manufacturerinfo', 'manufacturerinfoname', 'brand', 'productbrand', 'hersteller', 'fabricant', 'marque', 'fabricante'],
10
+ mfr_post: ['manufactureraddress', 'manufacturerpostaladdress', 'manufacturerinfoaddress', 'manufacturerpostal', 'brandaddress', 'herstelleradresse', 'adressefabricant'],
11
+ mfr_elec: ['manufactureremail', 'manufacturercontact', 'manufacturercontacturl', 'manufacturerurl', 'manufacturerinfoemail', 'herstelleremail', 'contactemail', 'emailfabricant'],
12
+ rp_name: ['responsibleperson', 'responsiblepersonname', 'euresponsibleperson', 'rpname', 'authorisedrepresentative', 'authorizedrepresentative', 'eurepresentative', 'bevollmaechtigter', 'importer', 'importerinfo', 'importername', 'personneresponsable'],
13
+ rp_post: ['responsiblepersonaddress', 'rpaddress', 'euresponsiblepersonaddress', 'importeraddress', 'importerinfoaddress', 'representativeaddress'],
14
+ rp_elec: ['responsiblepersonemail', 'rpemail', 'importeremail', 'representativeemail', 'responsiblepersoncontact'],
15
+ origin: ['countryoforigin', 'manufacturercountry', 'origin', 'madein', 'productioncountry', 'herkunftsland'],
16
+ pic: ['imagelink', 'image', 'imageurl', 'images', 'imagelink1', 'mainimage', 'picture', 'pictureurl', 'bild'],
17
+ ident: ['gtin', 'ean', 'upc', 'mpn', 'model', 'modelnumber', 'type', 'sku', 'productid', 'partnumber', 'itemnumber', 'artikelnummer'],
18
+ warn: ['warning', 'warnings', 'warningtext', 'safetyinformation', 'safetyinfo', 'safetywarning', 'warnhinweise', 'agewarning', 'hazardwarning'],
19
+ market: ['targetcountry', 'contentlanguage', 'language', 'market', 'locale', 'shippingcountry', 'sellingcountry', 'country'],
20
+ title: ['title', 'name', 'productname', 'producttitle', 'itemtitle', 'description'],
21
+ category: ['producttype', 'googleproductcategory', 'category', 'kategorie', 'categorie']
22
+ };
23
+
24
+ var EU = ('AT BE BG HR CY CZ DK EE FI FR DE GR EL HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE IS LI NO ' +
25
+ 'AUSTRIA BELGIUM BULGARIA CROATIA CYPRUS CZECHIA CZECHREPUBLIC DENMARK ESTONIA FINLAND FRANCE GERMANY DEUTSCHLAND GREECE HUNGARY IRELAND ITALY ITALIA LATVIA LITHUANIA LUXEMBOURG MALTA NETHERLANDS POLAND POLSKA PORTUGAL ROMANIA SLOVAKIA SLOVENIA SPAIN ESPANA SWEDEN ICELAND LIECHTENSTEIN NORWAY EU EEA').split(/\s+/);
26
+
27
+ var PLACEHOLDER = /^(n\/?a|na|tbd|tba|none|null|nil|-+|\.+|0|unknown|see packaging|on packaging|see box|see label|as above|same as above|refer to packaging|contact seller|not applicable|xxx+|todo)$/i;
28
+
29
+ var WARN_SIGNAL = /(\b\d\s*\+\s*(years|yrs)?|not suitable for children|choking|small parts|lithium|li-ion|battery|batteries|rechargeable|ce mark|ce-mark|\b(110|120|220|230|240)\s*v\b|mains|charger|power adapter|flammable|corrosive|toxic|irritant|aerosol|bleach|solvent|laser|toy\b|toys\b|plush|ride-on|scooter|helmet|cosmetic|candle)/i;
30
+
31
+ var LANG_OF = { DE: 'de', AT: 'de', CH: 'de', FR: 'fr', BE: 'fr', LU: 'fr', ES: 'es', IT: 'it', PL: 'pl', NL: 'nl', PT: 'pt', SE: 'sv', DK: 'da', FI: 'fi', CZ: 'cs', RO: 'ro', HU: 'hu', GR: 'el', EL: 'el' };
32
+ var LANG_MARK = {
33
+ de: /(achtung|warnung|warnhinweis|vorsicht|nicht geeignet|erstickungsgefahr)/i,
34
+ fr: /(attention|avertissement|ne convient pas|danger d|risque)/i,
35
+ es: /(advertencia|atenci|no apto|peligro)/i,
36
+ it: /(avvertenz|attenzione|non adatto|pericolo)/i,
37
+ pl: /(ostrze|uwaga|nie nadaje)/i,
38
+ nl: /(waarschuwing|let op|niet geschikt)/i,
39
+ pt: /(aviso|aten|n.o adequado|perigo)/i,
40
+ sv: /(varning|observera|ej l.mplig)/i,
41
+ da: /(advarsel|bem.rk|ikke egnet)/i,
42
+ fi: /(varoitus|huomio|ei sovellu)/i,
43
+ cs: /(varov.n|upozorn|nen. vhodn)/i,
44
+ ro: /(avertisment|aten|nu este potrivit)/i,
45
+ hu: /(figyelmeztet|vigy.zat|nem alkalmas)/i,
46
+ el: /(προσοχ|προειδοπο)/i
47
+ };
48
+ var EN_MARK = /(warning|caution|not suitable|choking hazard|keep away from)/i;
49
+
50
+ var NOREPLY = /^(no-?reply|donotreply|do-not-reply|noreply)@/i;
51
+
52
+ // ---- parsing -----------------------------------------------------------
53
+ function norm(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }
54
+ function blank(v) { var s = String(v == null ? '' : v).trim(); return s === '' || PLACEHOLDER.test(s); }
55
+
56
+ function splitRow(line, d) {
57
+ var out = [], cur = '', q = false;
58
+ for (var i = 0; i < line.length; i++) {
59
+ var c = line[i];
60
+ if (q) {
61
+ if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
62
+ else if (c === '"') q = false;
63
+ else cur += c;
64
+ } else if (c === '"') q = true;
65
+ else if (c === d) { out.push(cur); cur = ''; }
66
+ else cur += c;
67
+ }
68
+ out.push(cur);
69
+ return out.map(function (s) { return s.trim(); });
70
+ }
71
+
72
+ function delimOf(head) {
73
+ var best = ',', n = -1;
74
+ [',', ';', '\t', '|'].forEach(function (d) {
75
+ var c = splitRow(head, d).length;
76
+ if (c > n) { n = c; best = d; }
77
+ });
78
+ return best;
79
+ }
80
+
81
+ function parseCSV(text) {
82
+ var lines = text.replace(/\r/g, '').split('\n');
83
+ var hi = 0;
84
+ while (hi < lines.length && lines[hi].trim() === '') hi++;
85
+ if (hi >= lines.length) return null;
86
+ var d = delimOf(lines[hi]);
87
+ var header = splitRow(lines[hi], d).map(norm);
88
+ var rows = [];
89
+ for (var i = hi + 1; i < lines.length; i++) {
90
+ if (lines[i].trim() === '') continue;
91
+ var cells = splitRow(lines[i], d), rec = {};
92
+ for (var j = 0; j < header.length; j++) rec[header[j]] = cells[j] === undefined ? '' : cells[j];
93
+ rows.push({ line: i + 1, rec: rec });
94
+ }
95
+ return { header: header, rows: rows, headerLine: hi + 1 };
96
+ }
97
+
98
+ function parseXML(text) {
99
+ var lines = text.replace(/\r/g, '').split('\n');
100
+ var rows = [], header = {}, cur = null, start = 0;
101
+ for (var i = 0; i < lines.length; i++) {
102
+ var L = lines[i];
103
+ if (/<(item|entry|product)[\s>]/i.test(L)) { cur = {}; start = i + 1; continue; }
104
+ if (/<\/(item|entry|product)>/i.test(L)) { if (cur) rows.push({ line: start, rec: cur }); cur = null; continue; }
105
+ if (cur) {
106
+ var m = L.match(/<([A-Za-z0-9_:-]+)[^>]*>([\s\S]*?)<\/\1>/);
107
+ if (m) {
108
+ var k = norm(m[1].replace(/^.*:/, ''));
109
+ var v = m[2].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/, '$1').trim();
110
+ cur[k] = v; header[k] = 1;
111
+ }
112
+ }
113
+ }
114
+ return { header: Object.keys(header), rows: rows, headerLine: 1 };
115
+ }
116
+
117
+ // ---- helpers -----------------------------------------------------------
118
+ function colFor(header, group) {
119
+ for (var i = 0; i < COLS[group].length; i++) if (header.indexOf(COLS[group][i]) >= 0) return COLS[group][i];
120
+ return null;
121
+ }
122
+ function val(rec, header, group) {
123
+ var syn = COLS[group], first = null;
124
+ for (var i = 0; i < syn.length; i++) {
125
+ if (header.indexOf(syn[i]) >= 0) {
126
+ var v = String(rec[syn[i]] == null ? '' : rec[syn[i]]);
127
+ if (first === null) first = v;
128
+ if (!blank(v)) return v; // any filled synonym column satisfies the field
129
+ }
130
+ }
131
+ return first === null ? '' : first;
132
+ }
133
+ function anyVal(rec, header, groups) {
134
+ for (var i = 0; i < groups.length; i++) { var v = val(rec, header, groups[i]); if (!blank(v)) return v; }
135
+ return '';
136
+ }
137
+ function isEU(s) {
138
+ var t = String(s || '').toUpperCase().replace(/[^A-Z]/g, '');
139
+ if (!t) return null;
140
+ if (EU.indexOf(t) >= 0) return true;
141
+ for (var i = 0; i < EU.length; i++) if (EU[i].length > 2 && t.indexOf(EU[i]) >= 0) return true;
142
+ var codes = String(s).toUpperCase().match(/\b[A-Z]{2}\b/g) || [];
143
+ for (var j = 0; j < codes.length; j++) if (EU.indexOf(codes[j]) >= 0) return true;
144
+ return false;
145
+ }
146
+ function label(rec, header) {
147
+ var t = val(rec, header, 'title') || anyVal(rec, header, ['ident']);
148
+ t = t.replace(/\s+/g, ' ').trim();
149
+ return t ? ' — "' + (t.length > 46 ? t.slice(0, 46) + '…' : t) + '"' : '';
150
+ }
151
+
152
+ // ---- check -------------------------------------------------------------
153
+ function check(text, opts) {
154
+ opts = opts || {};
155
+ var findings = [];
156
+ var src = String(text || '');
157
+ var doc = /^\s*</.test(src) ? parseXML(src) : parseCSV(src);
158
+ if (!doc || !doc.rows.length) return { findings: findings };
159
+ var H = doc.header, rows = doc.rows, HL = doc.headerLine;
160
+
161
+ function add(id, line, extra) {
162
+ var r = RULE_BY_ID[id];
163
+ findings.push({ check: id, sev: r.sev, msg: r.msg + (extra || ''), line: line });
164
+ }
165
+
166
+ // file-level: a required column is not in the feed at all
167
+ [['mfr_name', 'A19a-NAME'], ['mfr_post', 'A19a-POST'], ['mfr_elec', 'A19a-ELEC'],
168
+ ['pic', 'A19c-PIC'], ['ident', 'A19c-ID']].forEach(function (p) {
169
+ if (!colFor(H, p[0])) add(p[1], HL, ' No such column in this feed — all ' + rows.length + ' rows fail.');
170
+ });
171
+
172
+ var nonEU = rows.filter(function (r) { return isEU(val(r.rec, H, 'origin')) === false; });
173
+ var rpCol = colFor(H, 'rp_name');
174
+ if (nonEU.length && !rpCol) add('A19b-RP', HL, ' No Responsible Person column in this feed — ' + nonEU.length + ' non-EU row(s) fail.');
175
+
176
+ var warnCol = colFor(H, 'warn');
177
+ var warnRows = rows.filter(function (r) {
178
+ var hay = val(r.rec, H, 'title') + ' ' + val(r.rec, H, 'category');
179
+ return WARN_SIGNAL.test(hay);
180
+ });
181
+ if (warnRows.length && !warnCol) add('A19d-WARN', HL, ' No warning/safety column in this feed — ' + warnRows.length + ' warning-bearing row(s) fail.');
182
+
183
+ rows.forEach(function (row) {
184
+ var rec = row.rec, ln = row.line, tag = label(rec, H);
185
+
186
+ if (colFor(H, 'mfr_name')) {
187
+ var mn = val(rec, H, 'mfr_name');
188
+ if (blank(mn)) add('A19a-NAME', ln, tag);
189
+ else if (/^(generic|unbranded|no ?brand|oem|noname|no name|assorted|various|own brand)$/i.test(mn.trim())) add('A19a-NOBRAND', ln, ' Found "' + mn.trim() + '".' + tag);
190
+ }
191
+ if (colFor(H, 'mfr_post') && blank(val(rec, H, 'mfr_post'))) add('A19a-POST', ln, tag);
192
+ if (colFor(H, 'mfr_elec')) {
193
+ var me = val(rec, H, 'mfr_elec');
194
+ if (blank(me)) add('A19a-ELEC', ln, tag);
195
+ else if (NOREPLY.test(me.trim())) add('A19a-NOREPLY', ln, ' Found "' + me.trim() + '".' + tag);
196
+ }
197
+ if (colFor(H, 'pic') && blank(val(rec, H, 'pic'))) add('A19c-PIC', ln, tag);
198
+ if (colFor(H, 'ident') && blank(anyVal(rec, H, ['ident']))) add('A19c-ID', ln, tag);
199
+
200
+ var eu = isEU(val(rec, H, 'origin'));
201
+ if (eu === false) {
202
+ var rp = rpCol ? val(rec, H, 'rp_name') : '';
203
+ if (blank(rp)) { if (rpCol) add('A19b-RP', ln, ' Origin "' + val(rec, H, 'origin') + '".' + tag); }
204
+ else {
205
+ var rpAddr = val(rec, H, 'rp_post');
206
+ if (!blank(rpAddr) && isEU(rpAddr) === false) add('A19b-RP-EU', ln, ' Responsible Person address "' + rpAddr + '".' + tag);
207
+ if (colFor(H, 'rp_elec') && blank(val(rec, H, 'rp_elec'))) add('A19b-RP-ELEC', ln, tag);
208
+ }
209
+ }
210
+
211
+ var hay = val(rec, H, 'title') + ' ' + val(rec, H, 'category');
212
+ if (WARN_SIGNAL.test(hay) && warnCol) {
213
+ var w = val(rec, H, 'warn');
214
+ if (blank(w)) add('A19d-WARN', ln, tag);
215
+ else {
216
+ var mk = val(rec, H, 'market').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
217
+ var want = LANG_OF[mk];
218
+ if (want && LANG_MARK[want] && !LANG_MARK[want].test(w) && EN_MARK.test(w)) {
219
+ add('A19d-LANG', ln, ' Market ' + mk + ' expects ' + want + ', warning reads English.' + tag);
220
+ }
221
+ }
222
+ }
223
+ });
224
+
225
+ findings.sort(function (a, b) { return a.line - b.line; });
226
+ return { findings: findings };
227
+ }
228
+
229
+ var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
230
+ if (typeof module !== 'undefined') module.exports = API;
231
+ if (typeof window !== 'undefined') window.GPSRENGINE = API;
package/license.js ADDED
@@ -0,0 +1,64 @@
1
+ // license.js — Polar licence check for the CLI / container. Generated by adapters.py; do not edit by hand.
2
+ 'use strict';
3
+ const https = require('https'), fs = require('fs'), path = require('path'), os = require('os');
4
+ const ORG_ID = 'a5cdf664-d8e7-4f87-8895-056717aaba17';
5
+ const BENEFIT_ID = '595fc7ee-c75e-4bb5-bc27-2237f5521698'; // s142 — this product's Polar benefit (s140 law: ask with benefit_id, or one key opens every product)
6
+ const BUY_URL = 'https://buy.polar.sh/polar_cl_MeEOXqXAf3FoF5aXVJjeeyaoJJNIODsmsm5CK0skgvG';
7
+ const SLUG = 'gpsr-listing-lint';
8
+ const GRACE_MS = 30 * 24 * 3600 * 1000; // after a successful check, 30 days work offline
9
+ const RECHECK_MS = 7 * 24 * 3600 * 1000; // re-ask Polar every 7 days (refunds / cancellations)
10
+ function storePath() { return path.join(process.env.READYSTACK_HOME || path.join(os.homedir(), '.config', 'readystack'), SLUG + '.json'); }
11
+ function load() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')); } catch (e) { return {}; } }
12
+ function save(o) { try { fs.mkdirSync(path.dirname(storePath()), { recursive: true }); fs.writeFileSync(storePath(), JSON.stringify(o)); } catch (e) { /* read-only home: still works for this run */ } }
13
+ function validate(key) {
14
+ return new Promise(function (resolve) {
15
+ if (!ORG_ID) return resolve({ ok: false, offline: false });
16
+ const body = JSON.stringify(/^[0-9a-f-]{36}$/.test(BENEFIT_ID) ? { key: key, organization_id: ORG_ID, benefit_id: BENEFIT_ID } : { key: key, organization_id: ORG_ID });
17
+ const req = https.request({ hostname: 'api.polar.sh', path: '/v1/customer-portal/license-keys/validate', method: 'POST', timeout: 8000,
18
+ headers: { 'content-type': 'application/json', 'polar-version': '2026-04', 'content-length': Buffer.byteLength(body) } }, function (res) {
19
+ let buf = ''; res.on('data', function (d) { buf += d; });
20
+ res.on('end', function () {
21
+ if (res.statusCode !== 200) return resolve({ ok: false, offline: false });
22
+ try { const j = JSON.parse(buf); resolve({ ok: j && (j.status === 'granted' || j.valid === true || !!j.id), offline: false }); }
23
+ catch (e) { resolve({ ok: false, offline: false }); }
24
+ });
25
+ });
26
+ req.on('timeout', function () { req.destroy(); resolve({ ok: false, offline: true }); });
27
+ req.on('error', function () { resolve({ ok: false, offline: true }); });
28
+ req.write(body); req.end();
29
+ });
30
+ }
31
+ async function ensure(explicitKey) {
32
+ const st = load();
33
+ const key = explicitKey || process.env.READYSTACK_LICENSE || st.key;
34
+ if (!key) return { ok: false, why: 'no_key' };
35
+ const age = Date.now() - (st.okAt || 0);
36
+ if (!explicitKey && st.key === key && age < RECHECK_MS) return { ok: true, cached: true };
37
+ const r = await validate(String(key).trim());
38
+ if (r.ok) { save({ key: String(key).trim(), okAt: Date.now() }); return { ok: true }; }
39
+ if (r.offline && st.key === key && age < GRACE_MS) return { ok: true, offline: true };
40
+ return { ok: false, why: r.offline ? 'offline' : 'invalid' };
41
+ }
42
+ // ★s134 — 구독 규칙 피드(층3 "바뀌면 업데이트"): 키 있는 손님만 · 7일마다 · 오프라인은 캐시. 워커 GET /api/rules/<slug>?key=
43
+ const FEED_URL = 'https://getreadystack.com/api/rules/';
44
+ function pullFeed() {
45
+ const st = load(); const key = process.env.READYSTACK_LICENSE || st.key; const cached = st.feed || null;
46
+ if (!key) return Promise.resolve(cached);
47
+ if (cached && (Date.now() - (st.feedAt || 0)) < RECHECK_MS) return Promise.resolve(cached);
48
+ return new Promise(function (resolve) {
49
+ let req;
50
+ try {
51
+ req = https.get(FEED_URL + encodeURIComponent(SLUG) + '?key=' + encodeURIComponent(key), { timeout: 8000, headers: { 'user-agent': 'readystack-cli' } }, function (res) {
52
+ let buf = ''; res.on('data', function (d) { buf += d; });
53
+ res.on('end', function () {
54
+ if (res.statusCode !== 200) return resolve(cached);
55
+ try { const j = JSON.parse(buf); if (!j || !Array.isArray(j.rules)) return resolve(cached); save(Object.assign(load(), { feed: j, feedAt: Date.now() })); resolve(j); }
56
+ catch (e) { resolve(cached); }
57
+ });
58
+ });
59
+ } catch (e) { return resolve(cached); }
60
+ req.on('timeout', function () { req.destroy(); resolve(cached); });
61
+ req.on('error', function () { resolve(cached); });
62
+ });
63
+ }
64
+ module.exports = { ensure, BUY_URL, storePath, pullFeed };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@readystack/gpsr-listing-lint",
3
+ "version": "1.0.0",
4
+ "description": "Audits a product feed row by row against Article 19 of the EU General Product Safety Regulation",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "gpsr",
11
+ "product feed",
12
+ "ecommerce",
13
+ "compliance",
14
+ "csv",
15
+ "eu",
16
+ "marketplace"
17
+ ],
18
+ "homepage": "https://getreadystack.com",
19
+ "funding": "https://buy.polar.sh/polar_cl_MeEOXqXAf3FoF5aXVJjeeyaoJJNIODsmsm5CK0skgvG",
20
+ "bin": {
21
+ "gpsr-listing-lint": "cli.js"
22
+ },
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "mcpName": "io.github.jmshinhwa/gpsr-listing-lint",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/jmshinhwa/readystack-themes.git",
30
+ "directory": "gpsr-listing-lint"
31
+ },
32
+ "files": [
33
+ "cli.js",
34
+ "license.js",
35
+ "rules.json",
36
+ "strings.json",
37
+ "README.md",
38
+ "LICENSE.txt",
39
+ "engine.js"
40
+ ]
41
+ }
package/rules.json ADDED
@@ -0,0 +1,74 @@
1
+ [
2
+ {
3
+ "id": "A19a-NAME",
4
+ "sev": "error",
5
+ "article": "GPSR Art. 19(a)",
6
+ "msg": "No manufacturer name in the offer. Art. 19(a) wants the name, registered trade name or trade mark of the manufacturer on the listing itself."
7
+ },
8
+ {
9
+ "id": "A19a-POST",
10
+ "sev": "error",
11
+ "article": "GPSR Art. 19(a)",
12
+ "msg": "No manufacturer postal address. Art. 19(a) wants a postal address at which the manufacturer can be contacted, shown in the offer."
13
+ },
14
+ {
15
+ "id": "A19a-ELEC",
16
+ "sev": "error",
17
+ "article": "GPSR Art. 19(a)",
18
+ "msg": "No manufacturer electronic address. Art. 19(a) wants an e-mail address or contact URL next to the postal one."
19
+ },
20
+ {
21
+ "id": "A19a-NOREPLY",
22
+ "sev": "warn",
23
+ "article": "GPSR Art. 19(a)",
24
+ "msg": "Electronic address is an unmonitored mailbox. Art. 19(a) asks for an address at which the manufacturer can be contacted, not one that bounces."
25
+ },
26
+ {
27
+ "id": "A19a-NOBRAND",
28
+ "sev": "warn",
29
+ "article": "GPSR Art. 19(a)",
30
+ "msg": "Manufacturer field holds a placeholder brand (generic / unbranded / OEM / the shop's own name). That is not a manufacturer identity."
31
+ },
32
+ {
33
+ "id": "A19b-RP",
34
+ "sev": "error",
35
+ "article": "GPSR Art. 19(b)",
36
+ "msg": "Manufacturer is established outside the EU/EEA and no Responsible Person is named. Art. 19(b) requires the name, postal and electronic address of the responsible person under Art. 16(1) GPSR or Art. 4(1) of Reg. (EU) 2019/1020."
37
+ },
38
+ {
39
+ "id": "A19b-RP-EU",
40
+ "sev": "error",
41
+ "article": "GPSR Art. 19(b)",
42
+ "msg": "Responsible Person is named but their address is outside the EU/EEA. The responsible person must be established in the Union."
43
+ },
44
+ {
45
+ "id": "A19b-RP-ELEC",
46
+ "sev": "error",
47
+ "article": "GPSR Art. 19(b)",
48
+ "msg": "Responsible Person has no electronic address. Art. 19(b) asks for name, postal AND electronic address."
49
+ },
50
+ {
51
+ "id": "A19c-PIC",
52
+ "sev": "error",
53
+ "article": "GPSR Art. 19(c)",
54
+ "msg": "No picture of the product. Art. 19(c) names the picture as part of the information allowing identification of the product."
55
+ },
56
+ {
57
+ "id": "A19c-ID",
58
+ "sev": "error",
59
+ "article": "GPSR Art. 19(c)",
60
+ "msg": "No product identifier. Art. 19(c) wants the type and any other product identifier (model, MPN, GTIN) in the offer."
61
+ },
62
+ {
63
+ "id": "A19d-WARN",
64
+ "sev": "error",
65
+ "article": "GPSR Art. 19(d)",
66
+ "msg": "Product carries warning-bearing signals (age limit, battery, CE, chemical, electrical) but the offer shows no warning or safety information. Art. 19(d) requires it in the offer, not only on the box."
67
+ },
68
+ {
69
+ "id": "A19d-LANG",
70
+ "sev": "warn",
71
+ "article": "GPSR Art. 19(d)",
72
+ "msg": "Warning text is not in the language of the target market. Art. 19(d) requires a language easily understood by consumers in the Member State where the product is made available."
73
+ }
74
+ ]
package/strings.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "GPSR Listing Lint",
3
+ "subtitle": "Audits a product feed row by row against Article 19 of the EU General Product Safety Regulation",
4
+ "bin": "gpsr-listing-lint",
5
+ "price": 29,
6
+ "free": "Open any product feed file and see every listing row that is missing a GPSR Article 19 field, with its line number and the exact article it fails — no key, nothing uploaded.",
7
+ "paid": "Export a dated evidence pack for the whole workspace catalogue — every feed file, every failing row, per-rule counts — as CSV and Markdown you can hand to a marketplace, importer or auditor.",
8
+ "need_key": "This option needs a licence (GPSR Listing Lint).",
9
+ "exts": []
10
+ }