tablefacts 0.1.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.
Files changed (98) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +313 -0
  5. package/bin/tablefacts.mjs +28 -0
  6. package/package.json +77 -0
  7. package/src/index.mjs +52 -0
  8. package/src/instagram/README.md +135 -0
  9. package/src/instagram/download.mjs +62 -0
  10. package/src/instagram/index.mjs +355 -0
  11. package/src/instagram/links.mjs +55 -0
  12. package/src/instagram/record.mjs +18 -0
  13. package/src/lib/edge.mjs +49 -0
  14. package/src/lib/env.mjs +35 -0
  15. package/src/lib/errors.mjs +39 -0
  16. package/src/lib/files.mjs +10 -0
  17. package/src/lib/images.mjs +20 -0
  18. package/src/lib/log.mjs +17 -0
  19. package/src/lib/photos.mjs +42 -0
  20. package/src/lib/playwright.mjs +13 -0
  21. package/src/lib/project.mjs +42 -0
  22. package/src/lib/text.mjs +7 -0
  23. package/src/lib/types.mjs +247 -0
  24. package/src/menu/README.md +97 -0
  25. package/src/menu/cluvi/config.mjs +33 -0
  26. package/src/menu/cluvi/extract.mjs +24 -0
  27. package/src/menu/cluvi/import.mjs +30 -0
  28. package/src/menu/cluvi/source.mjs +156 -0
  29. package/src/menu/index.mjs +9 -0
  30. package/src/menu/lib/db.mjs +119 -0
  31. package/src/menu/lib/import.mjs +126 -0
  32. package/src/menu/lib/menu.mjs +95 -0
  33. package/src/menu/lib/run.mjs +83 -0
  34. package/src/menu/raw/config.mjs +38 -0
  35. package/src/menu/raw/extract.mjs +72 -0
  36. package/src/menu/raw/import.mjs +99 -0
  37. package/src/menu/raw/normalize.mjs +120 -0
  38. package/src/menu/raw/source.mjs +116 -0
  39. package/src/menu/raw/vision.mjs +252 -0
  40. package/src/research/README.md +69 -0
  41. package/src/research/index.mjs +147 -0
  42. package/src/research/lib/google.mjs +93 -0
  43. package/src/research/lib/hours.mjs +109 -0
  44. package/src/research/lib/merge.mjs +119 -0
  45. package/src/research/lib/osm.mjs +49 -0
  46. package/src/research/lib/report.mjs +118 -0
  47. package/src/research/lib/social.mjs +49 -0
  48. package/src/research/lib/util.mjs +104 -0
  49. package/src/research/lib/website.mjs +285 -0
  50. package/src/research/research.mjs +67 -0
  51. package/src/tripadvisor/README.md +78 -0
  52. package/src/tripadvisor/index.mjs +178 -0
  53. package/src/tripadvisor/links.mjs +78 -0
  54. package/src/tripadvisor/photos.mjs +55 -0
  55. package/types/index.d.mts +65 -0
  56. package/types/instagram/download.d.mts +1 -0
  57. package/types/instagram/index.d.mts +13 -0
  58. package/types/instagram/links.d.mts +14 -0
  59. package/types/instagram/record.d.mts +1 -0
  60. package/types/lib/edge.d.mts +14 -0
  61. package/types/lib/env.d.mts +12 -0
  62. package/types/lib/errors.d.mts +25 -0
  63. package/types/lib/files.d.mts +1 -0
  64. package/types/lib/images.d.mts +6 -0
  65. package/types/lib/log.d.mts +5 -0
  66. package/types/lib/photos.d.mts +30 -0
  67. package/types/lib/playwright.d.mts +1677 -0
  68. package/types/lib/project.d.mts +32 -0
  69. package/types/lib/text.d.mts +4 -0
  70. package/types/lib/types.d.mts +668 -0
  71. package/types/menu/cluvi/config.d.mts +13 -0
  72. package/types/menu/cluvi/extract.d.mts +2 -0
  73. package/types/menu/cluvi/import.d.mts +35 -0
  74. package/types/menu/cluvi/source.d.mts +37 -0
  75. package/types/menu/index.d.mts +6 -0
  76. package/types/menu/lib/db.d.mts +23 -0
  77. package/types/menu/lib/import.d.mts +11 -0
  78. package/types/menu/lib/menu.d.mts +24 -0
  79. package/types/menu/lib/run.d.mts +27 -0
  80. package/types/menu/raw/config.d.mts +18 -0
  81. package/types/menu/raw/extract.d.mts +2 -0
  82. package/types/menu/raw/import.d.mts +64 -0
  83. package/types/menu/raw/normalize.d.mts +29 -0
  84. package/types/menu/raw/source.d.mts +19 -0
  85. package/types/menu/raw/vision.d.mts +13 -0
  86. package/types/research/index.d.mts +9 -0
  87. package/types/research/lib/google.d.mts +12 -0
  88. package/types/research/lib/hours.d.mts +22 -0
  89. package/types/research/lib/merge.d.mts +87 -0
  90. package/types/research/lib/osm.d.mts +36 -0
  91. package/types/research/lib/report.d.mts +6 -0
  92. package/types/research/lib/social.d.mts +118 -0
  93. package/types/research/lib/util.d.mts +45 -0
  94. package/types/research/lib/website.d.mts +283 -0
  95. package/types/research/research.d.mts +1 -0
  96. package/types/tripadvisor/index.d.mts +11 -0
  97. package/types/tripadvisor/links.d.mts +13 -0
  98. package/types/tripadvisor/photos.d.mts +1 -0
@@ -0,0 +1,49 @@
1
+ // Attaches to the user's own Edge window over the DevTools protocol, starting it when needed.
2
+ // Shared by the tools that drive a real browser (photos instagram, photos tripadvisor).
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+
7
+ export const DEFAULT_CDP = 'http://localhost:9222'
8
+ export const DEFAULT_EDGE_DIR = 'C:\\ig-edge'
9
+
10
+ const EDGE_PATHS = [
11
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
12
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
13
+ ]
14
+
15
+ /**
16
+ * Whether a browser answers on the DevTools address.
17
+ * @param {string} cdp e.g. http://localhost:9222
18
+ * @returns {Promise<boolean>}
19
+ */
20
+ export async function cdpUp(cdp) {
21
+ try {
22
+ return (await fetch(new URL('/json/version', cdp), { signal: AbortSignal.timeout(2000) })).ok
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ // Starts Edge with remote debugging when nothing answers on the `cdp` address (local only).
29
+ // It is detached, so it stays open after the run and keeps its logins for the next one.
30
+ /**
31
+ * @param {string} cdp DevTools address; only localhost can be started
32
+ * @param {string} [edgeDir] Edge profile folder. Default C:\ig-edge
33
+ * @returns {Promise<void>}
34
+ */
35
+ export async function ensureEdge(cdp, edgeDir) {
36
+ if (await cdpUp(cdp)) return
37
+ const url = new URL(cdp)
38
+ if (!['localhost', '127.0.0.1'].includes(url.hostname)) throw new Error(`no browser answers at ${cdp}`)
39
+ const exe = EDGE_PATHS.find((path) => existsSync(path))
40
+ if (!exe) throw new Error('Edge was not found in its usual folder; start it yourself with --remote-debugging-port')
41
+ const dir = resolve(edgeDir ?? DEFAULT_EDGE_DIR)
42
+ console.log(`Starting Edge on port ${url.port} (profile ${dir})`)
43
+ spawn(exe, [`--remote-debugging-port=${url.port}`, `--user-data-dir=${dir}`], { detached: true, stdio: 'ignore' }).unref()
44
+ for (let i = 0; i < 30; i++) {
45
+ await new Promise((done) => setTimeout(done, 1000))
46
+ if (await cdpUp(cdp)) return
47
+ }
48
+ throw new Error('Edge did not open its debugging port; close the Edge window that uses that profile and run again')
49
+ }
@@ -0,0 +1,35 @@
1
+ // Reads KEY=value env files into an env object (process.env by default) without overriding what is already set.
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { envFiles } from './project.mjs'
4
+
5
+ const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/
6
+
7
+ /** `env` when given, else process.env. */
8
+ export const resolveEnv = (env) => env ?? process.env
9
+
10
+ /** Loads `files` (default: the project's .env files) into `env`. The first file that sets a variable wins. Returns the files it read. */
11
+ export function loadEnvFiles(files = envFiles(), env = process.env) {
12
+ const target = resolveEnv(env)
13
+ const read = []
14
+ for (const file of files) {
15
+ if (!existsSync(file)) continue
16
+ read.push(file)
17
+ for (const line of readFileSync(file, 'utf8').replace(/^/, '').split(/\r?\n/)) {
18
+ const m = line.match(LINE)
19
+ if (!m || line.trim().startsWith('#') || m[1] in target) continue
20
+ target[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2')
21
+ }
22
+ }
23
+ return read
24
+ }
25
+
26
+ /**
27
+ * Loads the project's env files (`projectDir`, else TABLEFACTS_PROJECT or the current folder) or
28
+ * exactly `files` into `env` (default process.env). Never overrides a variable that is already set.
29
+ * Returns the files it read.
30
+ * @param {import('./types.mjs').LoadEnvOptions} [options]
31
+ * @returns {string[]}
32
+ */
33
+ export function loadEnv({ projectDir, files, env = process.env } = {}) {
34
+ return loadEnvFiles(files ?? envFiles(projectDir), env)
35
+ }
@@ -0,0 +1,39 @@
1
+ // The one error type the library throws on purpose, so callers can tell what went wrong from `code`:
2
+ // EUSAGE bad or missing arguments (the CLI exits with 2)
3
+ // ECONFIG missing key, database URL, unknown provider, no config url
4
+ // EDEPENDENCY an optional dependency (such as playwright) is not installed
5
+ // EFAILED a source failed or was blocked, fetched data did not validate, an unsafe import was refused
6
+
7
+ export class TablefactsError extends Error {
8
+ /**
9
+ * @param {string} message
10
+ * @param {import('./types.mjs').TablefactsErrorCode} code
11
+ * @param {{ cause?: unknown, option?: string }} [options] `option` names the library option the message is about.
12
+ */
13
+ constructor(message, code, options) {
14
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause })
15
+ this.name = 'TablefactsError'
16
+ /** @type {import('./types.mjs').TablefactsErrorCode} */
17
+ this.code = code
18
+ if (options?.option !== undefined) {
19
+ /** The library option the error is about, when it is about one. @type {string=} */
20
+ this.option = options.option
21
+ }
22
+ }
23
+ }
24
+
25
+ /** Bad or missing arguments: the CLIs exit with 2. */
26
+ export const usageError = (message) => new TablefactsError(message, 'EUSAGE')
27
+
28
+ /** The exit code a CLI uses for an error it reports: 2 for bad arguments (EUSAGE), 1 for anything else. */
29
+ export const exitCodeFor = (err) => (err?.code === 'EUSAGE' ? 2 : 1)
30
+
31
+ /** "fooBar" -> "foo-bar". */
32
+ export const kebab = (name) => String(name).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
33
+
34
+ /** An error about a library option. Write the option in backticks in the message, e.g. "`out` is required". */
35
+ export const optionError = (option, message, code = 'EUSAGE') => new TablefactsError(message, code, { option })
36
+
37
+ /** The message with `option` tokens replaced by CLI flag text from `flags` ({ out: '--out <folder>' }). */
38
+ export const cliMessage = (err, flags = {}) =>
39
+ String(err?.message ?? err).replace(/`([^`]+)`/g, (token, name) => (Object.hasOwn(flags, name) ? flags[name] : token))
@@ -0,0 +1,10 @@
1
+ import { access } from 'node:fs/promises'
2
+
3
+ export async function exists(path) {
4
+ try {
5
+ await access(path)
6
+ return true
7
+ } catch {
8
+ return false
9
+ }
10
+ }
@@ -0,0 +1,20 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { TablefactsError } from './errors.mjs'
4
+
5
+ /** Throws EFAILED unless the response is a good image: ok, an image/* content type and at least `minBytes` bytes. */
6
+ export function assertImageResponse({ ok, status, contentType, bytes, minBytes = 0, label } = {}) {
7
+ const fail = (text) => {
8
+ throw new TablefactsError(label ? `${label}: ${text}` : text, 'EFAILED')
9
+ }
10
+ if (!ok) fail(`HTTP ${status}`)
11
+ const type = contentType ?? ''
12
+ if (!type.startsWith('image/')) fail(type ? `not an image (${type})` : 'not an image')
13
+ if (minBytes > 0 && (bytes?.length ?? 0) < minBytes) fail('too small')
14
+ }
15
+
16
+ /** Writes the bytes to `file`, creating its folder first. */
17
+ export async function writeImage(file, bytes) {
18
+ await mkdir(dirname(file), { recursive: true })
19
+ await writeFile(file, bytes)
20
+ }
@@ -0,0 +1,17 @@
1
+ // One logger contract for every tool: log(message, level = 'info'), level is 'info' | 'warn' | 'error'.
2
+
3
+ export const noopLog = () => {}
4
+
5
+ /** For CLIs: info goes to stdout, warn and error to stderr. */
6
+ export const consoleLog = (message, level = 'info') => {
7
+ if (level === 'info') console.log(message)
8
+ else console.error(message)
9
+ }
10
+
11
+ /** Always returns a function that is called as log(message, level); tolerates a missing log or one that takes one argument. */
12
+ export function normalizeLog(log) {
13
+ if (typeof log !== 'function') return noopLog
14
+ return (message, level = 'info') => {
15
+ log(message, level)
16
+ }
17
+ }
@@ -0,0 +1,42 @@
1
+ // Scaffolding shared by the photo tools (Instagram, TripAdvisor).
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { exists } from './files.mjs'
5
+ import { resolveIn } from './project.mjs'
6
+
7
+ export const DELAY_MS = 2500
8
+
9
+ /** { saved, skipped, failed: [{ item, reason }], found? } where `found` ([{ name, url }]) exists only in a dry run. */
10
+ export function newSummary({ dryRun = false } = {}) {
11
+ const summary = { saved: 0, skipped: 0, failed: [] }
12
+ if (dryRun) summary.found = []
13
+ return summary
14
+ }
15
+
16
+ /** Resolves the output folder and creates it (not in a dry run), plus <out>/_debug when `debug`. */
17
+ export async function prepareOut({ out, dryRun = false, debug = false, projectDir } = {}) {
18
+ const outDir = resolveIn(projectDir, out)
19
+ if (!dryRun) await mkdir(outDir, { recursive: true })
20
+ const debugDir = debug ? join(outDir, '_debug') : null
21
+ if (debugDir) await mkdir(debugDir, { recursive: true })
22
+ return { outDir, debugDir }
23
+ }
24
+
25
+ /**
26
+ * Dry run: records { name, url } in summary.found. File already there: counts it as skipped.
27
+ * Otherwise awaits `save({ file, name, url })` and counts it as saved. A save error is not caught.
28
+ */
29
+ export async function storeFile({ outDir, name, url, dryRun, summary, log, save }) {
30
+ const file = join(outDir, name)
31
+ if (dryRun) {
32
+ summary.found.push({ name, url })
33
+ log(` ${name} <- ${url}`)
34
+ } else if (await exists(file)) {
35
+ summary.skipped++
36
+ log(` ${name} already there`)
37
+ } else {
38
+ await save({ file, name, url })
39
+ summary.saved++
40
+ log(` ${name} saved`)
41
+ }
42
+ }
@@ -0,0 +1,13 @@
1
+ // Playwright is an optional peer dependency: only the browser tools need it, so it loads on use.
2
+ import { TablefactsError } from './errors.mjs'
3
+
4
+ export async function loadPlaywright() {
5
+ try {
6
+ return await import('playwright')
7
+ } catch (error) {
8
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' && /playwright/.test(error.message ?? '')) {
9
+ throw new TablefactsError('playwright is not installed. The browser tools need it. Install it with: npm install playwright', 'EDEPENDENCY', { cause: error })
10
+ }
11
+ throw error
12
+ }
13
+ }
@@ -0,0 +1,42 @@
1
+ // Where a tool looks for the project it works on and where it keeps its own files.
2
+ // The project is the folder the command is run in (or TABLEFACTS_PROJECT), never the folder this
3
+ // library is installed in, so one copy of tablefacts serves every template. A library caller can
4
+ // pass a `projectDir` instead.
5
+ import { isAbsolute, join, resolve } from 'node:path'
6
+
7
+ /**
8
+ * `dir` when given, else TABLEFACTS_PROJECT, else the current folder.
9
+ * @param {string} [dir]
10
+ * @returns {string}
11
+ */
12
+ export const projectRoot = (dir) => resolve(dir ?? process.env.TABLEFACTS_PROJECT ?? process.cwd())
13
+
14
+ /**
15
+ * A path under <root>/.tablefacts, where output, caches and browser profiles go (git-ignore it).
16
+ * @param {string | undefined} root project folder (see projectRoot)
17
+ * @param {...string} parts
18
+ * @returns {string}
19
+ */
20
+ export const workDirIn = (root, ...parts) => join(projectRoot(root), '.tablefacts', ...parts)
21
+
22
+ /**
23
+ * A path under <project>/.tablefacts for the project in TABLEFACTS_PROJECT or the current folder.
24
+ * @param {...string} parts
25
+ * @returns {string}
26
+ */
27
+ export const workDir = (...parts) => workDirIn(undefined, ...parts)
28
+
29
+ /**
30
+ * The env files read, first one wins: <root>/.env, then <root>/data/.env (older templates).
31
+ * @param {string} [root] project folder (see projectRoot)
32
+ * @returns {string[]}
33
+ */
34
+ export const envFiles = (root) => [join(projectRoot(root), '.env'), join(projectRoot(root), 'data', '.env')]
35
+
36
+ /**
37
+ * An absolute path: relative paths resolve against the project (`projectDir`) when given, else the current folder.
38
+ * @param {string | undefined} projectDir
39
+ * @param {string} path
40
+ * @returns {string}
41
+ */
42
+ export const resolveIn = (projectDir, path) => (isAbsolute(path) ? path : resolve(projectDir ? projectRoot(projectDir) : process.cwd(), path))
@@ -0,0 +1,7 @@
1
+ // Text helpers shared by the research and menu tools.
2
+
3
+ /** Lowercase and accent-free, to compare names typed by different people. */
4
+ export const fold = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
5
+
6
+ /** "Café Ñandú & Co." to "cafe-nandu-co" (empty when nothing letter-like is left). */
7
+ export const slugify = (s) => fold(s).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -0,0 +1,247 @@
1
+ // Shared type definitions of the public API. This module exports nothing at runtime; it exists so
2
+ // the generated declarations (types/lib/types.d.ts) hold the option and result shapes.
3
+
4
+ /**
5
+ * Progress callback. `level` is 'info' by default; 'warn' for problems the run goes on from,
6
+ * 'error' for failures. Every tool is silent unless given one.
7
+ * @typedef {(message: string, level?: 'info' | 'warn' | 'error') => void} Log
8
+ */
9
+
10
+ /**
11
+ * An environment object such as process.env.
12
+ * @typedef {Record<string, string | undefined>} Env
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} ResearchOptions
17
+ * @property {string} name Restaurant name. Required.
18
+ * @property {string} [location] "City, Country".
19
+ * @property {string} [country] ISO country code, narrows the search (CO, MX, US...).
20
+ * @property {string} [website]
21
+ * @property {string} [instagram] Handle or URL.
22
+ * @property {string} [tripadvisor] TripAdvisor page URL.
23
+ * @property {string} [linktree] Linktree or other link-in-bio page.
24
+ * @property {number} [photos] Also download up to n Google and n website photos (reference only).
25
+ * @property {boolean} [render] Render the website with Playwright (sites built in JavaScript).
26
+ * @property {boolean} [google] Set false to skip Google even if a key is available. Default true.
27
+ * @property {string} [googleKey] Default: env.GOOGLE_PLACES_API_KEY.
28
+ * @property {Env} [env] Environment the keys are read from. Default process.env.
29
+ * @property {string} [out] Output folder; relative paths resolve against projectDir. Default <project>/.tablefacts/research/<slug>.
30
+ * @property {string} [projectDir] Project folder. Default: TABLEFACTS_PROJECT or the current folder.
31
+ * @property {Log} [log]
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} ResearchPhoto
36
+ * @property {string} file File name inside <outDir>/photos.
37
+ * @property {string} source Where it was downloaded from.
38
+ * @property {string} [alt]
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} ResearchProfile
43
+ * @property {Record<string, any>} fields Each field carries value, source and confidence.
44
+ * @property {string[]} [warnings]
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} ResearchResult
49
+ * @property {ResearchProfile} profile The merged profile.
50
+ * @property {string[]} notes What each source found or failed to find.
51
+ * @property {ResearchPhoto[]} photos
52
+ * @property {string} outDir Absolute output folder.
53
+ * @property {{ profile: string, report: string, setupAnswers: string }} files Absolute paths of the written files.
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} PhotoFailure
58
+ * @property {string} item The post, profile or page that failed.
59
+ * @property {string} reason
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} PhotoSummary
64
+ * @property {number} saved
65
+ * @property {number} skipped Files that were already there.
66
+ * @property {PhotoFailure[]} failed
67
+ * @property {{ name: string, url: string }[]} [found] Only present when dryRun is true.
68
+ */
69
+
70
+ /**
71
+ * At least one of `links`, `file` or `profile` is required.
72
+ * @typedef {object} InstagramOptions
73
+ * @property {string[]} [links] Post links.
74
+ * @property {string} [file] Text file with one link per line (resolved against projectDir).
75
+ * @property {string} out Folder the images are saved to (resolved against projectDir). Required.
76
+ * @property {string} [profile] Profile link or handle (with or without the at sign): download a whole profile (videos skipped).
77
+ * @property {number | 'all'} [pages] With `profile`, batches of posts to load. Default 1.
78
+ * @property {string} [cdp] Edge debugging address, e.g. http://localhost:9222. Started if nothing listens.
79
+ * @property {string} [edgeDir] Profile folder for the Edge that is started.
80
+ * @property {boolean} [viaGoogle] Reach toolzu through a Google search (CLI: --google). Default false.
81
+ * @property {string} [browser] Installed browser channel: 'msedge' or 'chrome'.
82
+ * @property {string} [userDataDir] Persistent browser profile (resolved against projectDir).
83
+ * @property {string} [projectDir] Project folder. Default: TABLEFACTS_PROJECT or the current folder.
84
+ * @property {boolean} [headed] Show the browser window.
85
+ * @property {boolean} [dryRun] List what would be saved without saving.
86
+ * @property {boolean} [debug] Keep screenshots in <out>/_debug.
87
+ * @property {Log} [log]
88
+ */
89
+
90
+ /**
91
+ * @typedef {object} TripadvisorOptions
92
+ * @property {string[]} links Restaurant page links. At least one valid link is required.
93
+ * @property {string} out Folder the images are saved to (resolved against projectDir). Required.
94
+ * @property {string} [cdp] Edge debugging address. Default http://localhost:9222.
95
+ * @property {string} [edgeDir] Profile folder for the Edge that is started.
96
+ * @property {number} [max] Stop after n photos per restaurant.
97
+ * @property {string} [projectDir] Project folder. Default: TABLEFACTS_PROJECT or the current folder.
98
+ * @property {boolean} [dryRun]
99
+ * @property {boolean} [debug]
100
+ * @property {Log} [log]
101
+ */
102
+
103
+ /**
104
+ * @typedef {object} MenuProduct
105
+ * @property {string} name
106
+ * @property {string | null} [description]
107
+ * @property {number} price Finite and not negative.
108
+ * @property {string} currency ISO 4217 code, e.g. COP.
109
+ * @property {string | null} [image_url] An https URL.
110
+ * @property {boolean} [recommended]
111
+ */
112
+
113
+ /**
114
+ * @typedef {object} MenuSection
115
+ * @property {string} name
116
+ * @property {MenuProduct[]} products
117
+ */
118
+
119
+ /**
120
+ * @typedef {object} MenuCategory
121
+ * @property {string} slug The key the site's dictionaries use.
122
+ * @property {string} name
123
+ * @property {MenuSection[]} sections
124
+ */
125
+
126
+ /** @typedef {MenuCategory[]} Menu */
127
+
128
+ /**
129
+ * @typedef {object} MenuTotals
130
+ * @property {number} categories
131
+ * @property {number} sections
132
+ * @property {number} products
133
+ * @property {number} withImage
134
+ */
135
+
136
+ /**
137
+ * @typedef {object} ImportOptions
138
+ * @property {boolean} [dryRun] Check and report, write nothing.
139
+ * @property {string} [json] Also save the extracted menu as JSON at this path (resolved against projectDir).
140
+ * @property {boolean} [replaceAll] Replace the whole menu, not only the categories in this import.
141
+ * @property {boolean} [force] Write even if the import has far fewer products than it replaces.
142
+ * @property {string} [databaseUrl] Default: env.SUPABASE_DB_URL.
143
+ * @property {Env} [env] Environment the database URL and keys are read from. Default process.env.
144
+ * @property {string} [projectDir] Project folder. Default: TABLEFACTS_PROJECT or the current folder.
145
+ * @property {Log} [log]
146
+ */
147
+
148
+ /**
149
+ * @typedef {object} ImportResult
150
+ * @property {MenuTotals} totals
151
+ * @property {string[]} notes
152
+ * @property {boolean} written
153
+ * @property {boolean} dryRun
154
+ * @property {{ label: string, current: { categories: number, products: number, kept: string[] } } | null} database Null when the database was not reached.
155
+ */
156
+
157
+ /**
158
+ * @typedef {ImportOptions & { menu: Menu, notes?: string[], title?: string }} ImportMenuOptions
159
+ */
160
+
161
+ /**
162
+ * Restaurant-specific part of the Cluvi source (src/menu/cluvi/config.mjs).
163
+ * @typedef {object} CluviConfig
164
+ * @property {string} [url] Any page of the restaurant's Cluvi menu.
165
+ * @property {{ slug: string, name: string, from: string[] }[]} [categories] Cluvi main categories folded into each site category.
166
+ * @property {Record<string, string>} [sections] Cluvi subcategory to section name.
167
+ */
168
+
169
+ /**
170
+ * Restaurant-specific part of the picture-menu source (src/menu/raw/config.mjs).
171
+ * @typedef {object} RawConfig
172
+ * @property {string} [url] Page with the menu pictures, or a direct image URL.
173
+ * @property {string} currency ISO code of the prices.
174
+ * @property {string} [thousands] Thousands separator the menu prints. Default ".".
175
+ * @property {string} [decimal] Decimal separator the menu prints. Default ",".
176
+ * @property {number} [scale] Multiplies every price. Default 1.
177
+ * @property {{ slug: string, name: string, groups?: ('food' | 'drink')[] }[]} [categories] Category that lists each group.
178
+ * @property {Record<string, string>} [placeIn] Section title to category slug.
179
+ * @property {Record<string, string>} [sections] Section title to the name stored.
180
+ * @property {string[]} [skipSections] Section titles to leave out.
181
+ */
182
+
183
+ /** @typedef {CluviConfig | RawConfig} MenuSourceConfig */
184
+
185
+ /**
186
+ * @typedef {object} CluviMenuOptions
187
+ * @property {string} [url] Default: the config's.
188
+ * @property {'on_table' | 'delivery' | 'take_away'} [service]
189
+ * @property {string} [lang]
190
+ * @property {CluviConfig} [config] Default: the cluvi config.mjs.
191
+ */
192
+
193
+ /** @typedef {ImportOptions & CluviMenuOptions} ImportCluviOptions */
194
+
195
+ /**
196
+ * @typedef {object} ImageMenuReadOptions
197
+ * @property {string[]} [urls] Pages or image URLs. Default: the config's url.
198
+ * @property {string | number[]} [only] Pages to read, e.g. '1,3-5' or [1, 3, 4, 5].
199
+ * @property {string} [provider] Vision provider, see `providers`. Default MENU_VISION_PROVIDER or `defaultProvider`.
200
+ * @property {string} [model]
201
+ * @property {number} [minWidth] Ignore images declaring a smaller width. Default 500.
202
+ * @property {string} [apiKey] Default: the provider's key in env.
203
+ * @property {boolean} [refresh] Read the pages again instead of using the saved transcriptions.
204
+ * @property {RawConfig} [config] Default: the raw config.mjs.
205
+ */
206
+
207
+ /** @typedef {ImportOptions & ImageMenuReadOptions} ImportImageMenuOptions */
208
+
209
+ /**
210
+ * @typedef {object} ListMenuImagesOptions
211
+ * @property {string[]} [urls]
212
+ * @property {string | number[]} [only]
213
+ * @property {number} [minWidth]
214
+ * @property {RawConfig} [config]
215
+ */
216
+
217
+ /**
218
+ * @typedef {object} MenuImage
219
+ * @property {number} number Position as the CLI's --list numbers it.
220
+ * @property {string} url
221
+ * @property {string} alt
222
+ */
223
+
224
+ /**
225
+ * @typedef {object} PriceFormat
226
+ * @property {string} [thousands] Default ".".
227
+ * @property {string} [decimal] Default ",".
228
+ * @property {number} [scale] Default 1.
229
+ */
230
+
231
+ /**
232
+ * @typedef {object} VisionProvider
233
+ * @property {string} label
234
+ * @property {string} keyName Environment variable holding the API key.
235
+ * @property {string} defaultModel
236
+ */
237
+
238
+ /**
239
+ * @typedef {object} LoadEnvOptions
240
+ * @property {string} [projectDir] Project folder whose .env files are read.
241
+ * @property {string[]} [files] Exactly these files instead of the project's.
242
+ * @property {Env} [env] Object the variables are set on. Default process.env.
243
+ */
244
+
245
+ /** @typedef {'EUSAGE' | 'ECONFIG' | 'EDEPENDENCY' | 'EFAILED'} TablefactsErrorCode */
246
+
247
+ export {}
@@ -0,0 +1,97 @@
1
+ # Menu import
2
+
3
+ Scripts that read a restaurant's menu from the website that hosts it and write it into the Supabase menu tables the site reads (`menu_categories`, `menu_sections`, `menu_products`; the Cannario templates ship them as `supabase/migrations/0001_menu.sql`, and the columns written are listed in the [main README](../../README.md#supabase-tables)). One folder per source:
4
+
5
+ | Source | Folder | Status |
6
+ | --- | --- | --- |
7
+ | Cluvi (`<restaurant>.cluvi.co`) | `cluvi/` | working |
8
+ | Menu that is only pictures (one image per page) | `raw/` | working, API calls untested |
9
+
10
+ The same code is available as functions: `importCluvi`, `importImageMenu`, `listMenuImages` and `importMenu` (see the [main README](../../README.md#use-it-as-a-library)). Each source's `config.mjs` can be replaced with the `config` option, so a script can import a menu without editing the package:
11
+
12
+ ```js
13
+ import { importCluvi, listMenuImages } from 'tablefacts'
14
+
15
+ await importCluvi({
16
+ url: 'https://gaucho.cluvi.co/gaucho/maincategories',
17
+ config: { categories: [{ slug: 'cocina', name: 'Comida', from: ['Entradas', 'Fuertes'] }], sections: {} },
18
+ env: { SUPABASE_DB_URL: process.env.MY_DB_URL }, // keys are read from `env` (default process.env)
19
+ dryRun: true,
20
+ log: (message, level) => console.log(level ?? 'info', message),
21
+ })
22
+ const pictures = await listMenuImages({ urls: ['https://example.com/carta'], config: { currency: 'COP' } })
23
+ ```
24
+
25
+ - **Cluvi `config`**: `{ url?, categories: [{ slug, name, from[] }], sections: { 'Cluvi subcategory': 'SECTION NAME' } }`.
26
+ - **Picture `config`**: `{ url?, currency, thousands, decimal, scale, categories: [{ slug, name, groups: ['food' | 'drink'] }], placeIn, sections, skipSections }`. `currency` is required.
27
+ - **Options every import takes**: `dryRun`, `json` (relative paths resolve against `projectDir`), `replaceAll`, `force`, `databaseUrl` (default `env.SUPABASE_DB_URL`), `env`, `projectDir` and `log(message, level)` (`'info'`, `'warn'` for notes, `'error'`).
28
+ - **Result**: `{ totals, notes, written, dryRun, database }`; `database` is `{ label, current: { categories, products, kept } }` once the database was inspected, and `null` when it was not reached (a dry run without a URL).
29
+ - **Errors** are `TablefactsError`: `ECONFIG` (no URL, unknown provider, no key, bad currency), `EFAILED` (invalid menu, no products, import refused without `force`), `EUSAGE` (bad `only`). `err.option` names the option; the CLI shows the flag (`--force`, `--only`) and exits `2` for `EUSAGE`, `1` otherwise.
30
+
31
+ ## Setup
32
+
33
+ 1. Install the package in the project (`npm install --save-dev tablefacts`); it brings `pg`. Run the commands from the project's folder.
34
+ 2. Apply the migration to your Supabase project.
35
+ 3. `cp .env.example .env` and set `SUPABASE_DB_URL` to the pooler URL from the Supabase dashboard (Connect > Transaction pooler), with the real password.
36
+
37
+ `SUPABASE_DB_URL` bypasses row level security. It stays in `.env` (git-ignored). Never copy it into a browser-exposed variable (such as `NEXT_PUBLIC_*`): the site only uses the publishable key.
38
+
39
+ ## Run (Cluvi)
40
+
41
+ ```bash
42
+ tablefacts menu cluvi --dry-run # extract and check, show what would change, write nothing
43
+ tablefacts menu cluvi # replace the menu in Supabase
44
+ tablefacts menu cluvi <menu-url> # another restaurant
45
+ tablefacts menu cluvi --help
46
+ ```
47
+
48
+ Edit `cluvi/config.mjs` first: the menu URL, how Cluvi's main categories fold into the site's categories, and section renames. The run prints what the site still needs (dictionary keys, `content/qr.ts`).
49
+
50
+ ## Run (menu that is only pictures)
51
+
52
+ For a restaurant whose site shows the menu as a gallery of page images, such as <https://www.mombasa.co/carta-restaurante-espanol/>. Each page is transcribed by a vision model from one of three providers, so it needs that provider's key in `.env`:
53
+
54
+ | `--provider` | Key in `.env` | Default `--model` |
55
+ | --- | --- | --- |
56
+ | `anthropic` (default) | `ANTHROPIC_API_KEY` | `claude-sonnet-5-5` |
57
+ | `gemini` | `GEMINI_API_KEY` | `gemini-3.8-flash` |
58
+ | `groq` | `GROQ_API_KEY` | `qwen/qwen3.8-27b` |
59
+
60
+ Choose the provider per run with `--provider`, or once for every run with `MENU_VISION_PROVIDER` in `.env`.
61
+
62
+ ```bash
63
+ tablefacts menu raw --list # show the pictures found; no key needed, nothing read or written
64
+ tablefacts menu raw --dry-run # read the pages, show the menu and the checks, write nothing
65
+ tablefacts menu raw --only 3,9 --dry-run # try two pages first
66
+ tablefacts menu raw --provider gemini --dry-run # the same, read by Gemini
67
+ tablefacts menu raw # replace the menu in Supabase
68
+ tablefacts menu raw <page-or-image-url>... # another restaurant
69
+ ```
70
+
71
+ Edit `raw/config.mjs` first: the URL, the currency, how the menu prints prices (`$95.000` is `thousands: "."`, `decimal: ","`) and which category each section goes to.
72
+
73
+ - `raw/source.mjs` takes the large JPG/PNG/WebP images of the page in document order (full size, not the thumbnails; logos and icons are skipped by width) and downloads them. A site that builds its gallery with JavaScript shows up as "no images": pass the image URLs instead.
74
+ - `raw/vision.mjs` has the model transcribe each page into sections and dishes with one request: a forced tool call for Anthropic, JSON held to the same schema for Gemini (`generateContent`) and Groq (strict structured output), so every provider hands back the same shape. Prices come back as printed text and `raw/normalize.mjs` parses them, so a thousands separator is never guessed by the model. Adding a provider is one more entry in `providers` there.
75
+ - Groq serves a single vision model, `qwen/qwen3.8-27b`, and it is a preview one: if Groq retires it, pass its successor with `--model`.
76
+ - Rate limits (HTTP 429) are retried after the wait the service asks for, up to a minute; for a longer wait the run stops with the service's message. Pages already read are saved (below), so run it again later.
77
+ - The model tags each section food, drink or other; `config.categories` maps those to the site's categories. A section with no heading continues the one before it, even across pages. A dish with several price columns (bottle and glass) becomes one product per column, "Name (Botella)".
78
+ - Each page's transcription is saved in `<project>/.tablefacts/cache/<host>/<id>.json` (git-ignored) and reused on the next run, so re-running costs nothing. Edit a file there to fix a misread page; `--refresh` reads everything again. The saved pages are shared by all providers, so after switching provider or model, add `--refresh` or the pages already read are reused.
79
+ - **Read the dry run against the pictures before writing.** The model can misread small print, and a wrong price is worse than a missing dish. Pages it was unsure of are listed in the notes. There are no dish photos, so `image_url` is empty.
80
+
81
+ ## What it does (Cluvi)
82
+
83
+ - Cluvi main category > site category, subcategory > section (name in UPPERCASE), product > product. Products keep the order Cluvi shows (its `order` field). Cluvi's "important" flag is `recommended`. Descriptions are converted from HTML to text.
84
+ - The write is one transaction. By default only the categories in the import are replaced (their sections and products with them), so re-running never duplicates. `--replace-all` replaces the whole menu, which also removes the template's sample categories.
85
+ - It refuses an import with no products, or with under half the products it replaces (`--force` overrides), so a broken extraction cannot empty the menu.
86
+ - Photos stay on Cluvi's CDN: `image_url` holds the Cluvi URL, and your site must allow `images.cluvi.com` and `images-mini.cluvi.com` as image hosts (a template with `menuImageHosts` in `frontend/src/content/site.ts` gets a warning from the run when they are missing). If the restaurant leaves Cluvi, the photos must be re-hosted (Supabase Storage needs a different credential than the DB URL).
87
+
88
+ ## Known limits
89
+
90
+ - Cluvi has no public API. `cluvi/source.mjs` calls the two JSON endpoints its web app calls; if they change, that file is the one to fix.
91
+ - Products with options (add-ons, sizes) import their base price only. Products Cluvi hides the price of (price 0) import as 0 and the site shows 0.
92
+ - The Supabase pooler's certificate is not trusted by Node, so the connection is encrypted but the certificate is not verified unless `sslmode` is in the URL.
93
+ - Dish names and descriptions are not translated, as elsewhere in the site.
94
+
95
+ ## Adding a source
96
+
97
+ Create `src/menu/<source>/` with a script that calls `runImport` from `lib/run.mjs` and returns the menu in the shape described in `lib/menu.mjs`. The flags, checks and the database write are shared. If the source's photos stay on its own CDN, allow that host in your site's image settings.