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.
- package/.env.example +14 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tablefacts.mjs +28 -0
- package/package.json +77 -0
- package/src/index.mjs +52 -0
- package/src/instagram/README.md +135 -0
- package/src/instagram/download.mjs +62 -0
- package/src/instagram/index.mjs +355 -0
- package/src/instagram/links.mjs +55 -0
- package/src/instagram/record.mjs +18 -0
- package/src/lib/edge.mjs +49 -0
- package/src/lib/env.mjs +35 -0
- package/src/lib/errors.mjs +39 -0
- package/src/lib/files.mjs +10 -0
- package/src/lib/images.mjs +20 -0
- package/src/lib/log.mjs +17 -0
- package/src/lib/photos.mjs +42 -0
- package/src/lib/playwright.mjs +13 -0
- package/src/lib/project.mjs +42 -0
- package/src/lib/text.mjs +7 -0
- package/src/lib/types.mjs +247 -0
- package/src/menu/README.md +97 -0
- package/src/menu/cluvi/config.mjs +33 -0
- package/src/menu/cluvi/extract.mjs +24 -0
- package/src/menu/cluvi/import.mjs +30 -0
- package/src/menu/cluvi/source.mjs +156 -0
- package/src/menu/index.mjs +9 -0
- package/src/menu/lib/db.mjs +119 -0
- package/src/menu/lib/import.mjs +126 -0
- package/src/menu/lib/menu.mjs +95 -0
- package/src/menu/lib/run.mjs +83 -0
- package/src/menu/raw/config.mjs +38 -0
- package/src/menu/raw/extract.mjs +72 -0
- package/src/menu/raw/import.mjs +99 -0
- package/src/menu/raw/normalize.mjs +120 -0
- package/src/menu/raw/source.mjs +116 -0
- package/src/menu/raw/vision.mjs +252 -0
- package/src/research/README.md +69 -0
- package/src/research/index.mjs +147 -0
- package/src/research/lib/google.mjs +93 -0
- package/src/research/lib/hours.mjs +109 -0
- package/src/research/lib/merge.mjs +119 -0
- package/src/research/lib/osm.mjs +49 -0
- package/src/research/lib/report.mjs +118 -0
- package/src/research/lib/social.mjs +49 -0
- package/src/research/lib/util.mjs +104 -0
- package/src/research/lib/website.mjs +285 -0
- package/src/research/research.mjs +67 -0
- package/src/tripadvisor/README.md +78 -0
- package/src/tripadvisor/index.mjs +178 -0
- package/src/tripadvisor/links.mjs +78 -0
- package/src/tripadvisor/photos.mjs +55 -0
- package/types/index.d.mts +65 -0
- package/types/instagram/download.d.mts +1 -0
- package/types/instagram/index.d.mts +13 -0
- package/types/instagram/links.d.mts +14 -0
- package/types/instagram/record.d.mts +1 -0
- package/types/lib/edge.d.mts +14 -0
- package/types/lib/env.d.mts +12 -0
- package/types/lib/errors.d.mts +25 -0
- package/types/lib/files.d.mts +1 -0
- package/types/lib/images.d.mts +6 -0
- package/types/lib/log.d.mts +5 -0
- package/types/lib/photos.d.mts +30 -0
- package/types/lib/playwright.d.mts +1677 -0
- package/types/lib/project.d.mts +32 -0
- package/types/lib/text.d.mts +4 -0
- package/types/lib/types.d.mts +668 -0
- package/types/menu/cluvi/config.d.mts +13 -0
- package/types/menu/cluvi/extract.d.mts +2 -0
- package/types/menu/cluvi/import.d.mts +35 -0
- package/types/menu/cluvi/source.d.mts +37 -0
- package/types/menu/index.d.mts +6 -0
- package/types/menu/lib/db.d.mts +23 -0
- package/types/menu/lib/import.d.mts +11 -0
- package/types/menu/lib/menu.d.mts +24 -0
- package/types/menu/lib/run.d.mts +27 -0
- package/types/menu/raw/config.d.mts +18 -0
- package/types/menu/raw/extract.d.mts +2 -0
- package/types/menu/raw/import.d.mts +64 -0
- package/types/menu/raw/normalize.d.mts +29 -0
- package/types/menu/raw/source.d.mts +19 -0
- package/types/menu/raw/vision.d.mts +13 -0
- package/types/research/index.d.mts +9 -0
- package/types/research/lib/google.d.mts +12 -0
- package/types/research/lib/hours.d.mts +22 -0
- package/types/research/lib/merge.d.mts +87 -0
- package/types/research/lib/osm.d.mts +36 -0
- package/types/research/lib/report.d.mts +6 -0
- package/types/research/lib/social.d.mts +118 -0
- package/types/research/lib/util.d.mts +45 -0
- package/types/research/lib/website.d.mts +283 -0
- package/types/research/research.d.mts +1 -0
- package/types/tripadvisor/index.d.mts +11 -0
- package/types/tripadvisor/links.d.mts +13 -0
- package/types/tripadvisor/photos.d.mts +1 -0
package/src/lib/edge.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/lib/env.mjs
ADDED
|
@@ -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,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
|
+
}
|
package/src/lib/log.mjs
ADDED
|
@@ -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))
|
package/src/lib/text.mjs
ADDED
|
@@ -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.
|