polite-crawler 0.1.3 → 0.2.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 (76) hide show
  1. package/README.md +50 -121
  2. package/build/{browser-CKRNbIDT.mjs → browser-56dBBLec.mjs} +7 -3
  3. package/build/browser-56dBBLec.mjs.map +1 -0
  4. package/build/classify.d.mts +50 -2
  5. package/build/{cli-daemon--cTmq0Z5.mjs → cli-daemon-qS4PH2bM.mjs} +3 -3
  6. package/build/{cli-daemon--cTmq0Z5.mjs.map → cli-daemon-qS4PH2bM.mjs.map} +1 -1
  7. package/build/cli-recipes-qbLRVThF.mjs +59 -0
  8. package/build/cli-recipes-qbLRVThF.mjs.map +1 -0
  9. package/build/cli.mjs +431 -27
  10. package/build/cli.mjs.map +1 -1
  11. package/build/{daemon-fZTE24P5.mjs → daemon-BXBbUrni.mjs} +11 -7
  12. package/build/daemon-BXBbUrni.mjs.map +1 -0
  13. package/build/{diff-NQcQqKe9.mjs → diff-CpKN0Jdc.mjs} +7 -3
  14. package/build/{diff-NQcQqKe9.mjs.map → diff-CpKN0Jdc.mjs.map} +1 -1
  15. package/build/{extract-ByFfc8Zk.mjs → extract-DqeqiNHb.mjs} +12 -4
  16. package/build/extract-DqeqiNHb.mjs.map +1 -0
  17. package/build/{html-BACljlkf.d.mts → html-DMgIeQfJ.d.mts} +16 -2
  18. package/build/html.d.mts +1 -2
  19. package/build/index.d.mts +4227 -40
  20. package/build/index.mjs +17 -901
  21. package/build/{log-Dx-wXjJl.mjs → log-CmBWcyQm.mjs} +5 -1
  22. package/build/log-CmBWcyQm.mjs.map +1 -0
  23. package/build/mcp-package-Y0bREnOS.mjs +40 -0
  24. package/build/mcp-package-Y0bREnOS.mjs.map +1 -0
  25. package/build/{ollama-BJxkjny4.mjs → ollama-D1xRh3hY.mjs} +7 -3
  26. package/build/ollama-D1xRh3hY.mjs.map +1 -0
  27. package/build/{open-C4fhXJgD.mjs → open-CEo22Hpu.mjs} +84 -296
  28. package/build/open-CEo22Hpu.mjs.map +1 -0
  29. package/build/politeness.d.mts +366 -2
  30. package/build/recipe-B2SkepDZ.mjs +285 -0
  31. package/build/recipe-B2SkepDZ.mjs.map +1 -0
  32. package/build/recipe-product-CyZE3Ukd.mjs +914 -0
  33. package/build/recipe-product-CyZE3Ukd.mjs.map +1 -0
  34. package/build/{render-C1CUraWF.mjs → render-CKE2LILH.mjs} +10 -3
  35. package/build/render-CKE2LILH.mjs.map +1 -0
  36. package/build/robots.d.mts +143 -2
  37. package/build/robots.mjs +1 -1
  38. package/build/robots.mjs.map +1 -1
  39. package/build/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-8H4AJuhK.mjs} +1 -0
  40. package/build/{runtime-JToRO63o.mjs → runtime-C_2BQibH.mjs} +84 -18
  41. package/build/runtime-C_2BQibH.mjs.map +1 -0
  42. package/build/signals.d.mts +45 -2
  43. package/build/{transformers-BQMlaGj4.mjs → transformers-BNiuiLxT.mjs} +3 -3
  44. package/build/transformers-BNiuiLxT.mjs.map +1 -0
  45. package/build/{version-ntbY-6wh.mjs → version-Djva7JkN.mjs} +7 -2
  46. package/build/version-Djva7JkN.mjs.map +1 -0
  47. package/build/{wacz-BFNG42Kt.mjs → wacz-DKk2HBmx.mjs} +4 -4
  48. package/build/{wacz-BFNG42Kt.mjs.map → wacz-DKk2HBmx.mjs.map} +1 -1
  49. package/build/{warc-CVS0t1fW.mjs → warc-C_ew76bV.mjs} +4 -4
  50. package/build/{warc-CVS0t1fW.mjs.map → warc-C_ew76bV.mjs.map} +1 -1
  51. package/build/{webbotauth-CjJdTAfE.mjs → webbotauth-Dq_dA_u3.mjs} +2 -2
  52. package/build/webbotauth-Dq_dA_u3.mjs.map +1 -0
  53. package/package.json +5 -11
  54. package/build/browser-CKRNbIDT.mjs.map +0 -1
  55. package/build/classify-CrfyXcwo.d.mts +0 -51
  56. package/build/daemon-DP4EqjFL.d.mts +0 -3772
  57. package/build/daemon-fZTE24P5.mjs.map +0 -1
  58. package/build/extract-ByFfc8Zk.mjs.map +0 -1
  59. package/build/index.mjs.map +0 -1
  60. package/build/log-Dx-wXjJl.mjs.map +0 -1
  61. package/build/loopback-C_QYowSf.mjs +0 -22
  62. package/build/loopback-C_QYowSf.mjs.map +0 -1
  63. package/build/mcp.d.mts +0 -199
  64. package/build/mcp.mjs +0 -1555
  65. package/build/mcp.mjs.map +0 -1
  66. package/build/ollama-BJxkjny4.mjs.map +0 -1
  67. package/build/open-C4fhXJgD.mjs.map +0 -1
  68. package/build/politeness-yI3xBEx9.d.mts +0 -367
  69. package/build/render-C1CUraWF.mjs.map +0 -1
  70. package/build/robots-R-qaEBOb.d.mts +0 -144
  71. package/build/runtime-JToRO63o.mjs.map +0 -1
  72. package/build/signals-BHpy2xb2.d.mts +0 -46
  73. package/build/transformers-BQMlaGj4.mjs.map +0 -1
  74. package/build/urls-BTDSgDLC.d.mts +0 -16
  75. package/build/version-ntbY-6wh.mjs.map +0 -1
  76. package/build/webbotauth-CjJdTAfE.mjs.map +0 -1
package/build/cli.mjs CHANGED
@@ -1,10 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { i as durationMs, r as VERSION } from "./version-ntbY-6wh.mjs";
3
- import { t as isLoopbackHost } from "./loopback-C_QYowSf.mjs";
2
+ import { i as durationMs, r as VERSION } from "./version-Djva7JkN.mjs";
4
3
  import { join, resolve } from "node:path";
5
4
  import { readFileSync } from "node:fs";
5
+ import { isIP } from "node:net";
6
6
  import { parseArgs } from "node:util";
7
7
  import { homedir } from "node:os";
8
+ //#region src/loopback.ts
9
+ /**
10
+ * This function tells if a host name or an address is a loopback host: `localhost`, an address in
11
+ * `127.0.0.0/8`, or `::1`. The MCP server over HTTP binds to a loopback host unless the operator
12
+ * permits a different host. The function imports only Node.js, so the command line uses it before
13
+ * the store opens.
14
+ */
15
+ const isLoopbackHost = (host) => {
16
+ const name = host.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
17
+ if (name === "localhost" || name.endsWith(".localhost")) return true;
18
+ const mapped = name.startsWith("::ffff:") ? name.slice(7) : name;
19
+ if (isIP(mapped) === 4) return mapped.startsWith("127.");
20
+ if (isIP(name) === 6) return /^(?:0{0,4}:){7}0{0,3}1$|^::1$/.test(name);
21
+ return false;
22
+ };
23
+ //#endregion
8
24
  //#region src/cli-line.ts
9
25
  /** A usage error is a wrong command line. It exits with code 2. */
10
26
  var UsageError = class extends Error {};
@@ -103,6 +119,18 @@ const OPTIONS = {
103
119
  "ollama-url": { type: "string" },
104
120
  "allow-remote-ollama": { type: "boolean" },
105
121
  "max-docs": { type: "string" },
122
+ name: { type: "string" },
123
+ "require-name": { type: "boolean" },
124
+ address: { type: "string" },
125
+ lat: { type: "string" },
126
+ lon: { type: "string" },
127
+ osm: { type: "string" },
128
+ pages: { type: "string" },
129
+ compare: { type: "string" },
130
+ "on-behalf-of": {
131
+ type: "string",
132
+ multiple: true
133
+ },
106
134
  ...EXPORT_OPTIONS
107
135
  };
108
136
  /** These options belong to `daemon`, and not to `daemon status`. */
@@ -120,23 +148,34 @@ const DAEMON_RUN_OPTIONS = [
120
148
  "mcp-token-file",
121
149
  "allow-remote"
122
150
  ];
151
+ /** These options belong to `venue` only. */
152
+ const VENUE_OPTIONS = [
153
+ "name",
154
+ "require-name",
155
+ "address",
156
+ "lat",
157
+ "lon",
158
+ "osm",
159
+ "pages",
160
+ "compare"
161
+ ];
123
162
  /** These options belong to one command, or to the commands that the text names. */
124
163
  const OWNER = {
125
- render: "fetch crawl watch",
126
- "max-age": "fetch crawl discover",
164
+ render: "fetch crawl watch venue product",
165
+ "max-age": "fetch crawl discover venue product",
127
166
  limit: "find",
128
167
  kind: "find embed",
129
168
  rank: "find",
130
169
  from: "read diff",
131
170
  length: "read",
132
- "user-provided": "read extract",
171
+ "user-provided": "read extract venue product",
133
172
  "allow-user-provided": "mcp daemon",
134
173
  ...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
135
174
  "max-urls": "discover",
136
175
  feed: "discover",
137
176
  "max-pages": "crawl watch",
138
177
  "max-depth": "crawl",
139
- budget: "crawl watch embed",
178
+ budget: "crawl watch embed venue product",
140
179
  include: "crawl",
141
180
  exclude: "crawl",
142
181
  sitemaps: "crawl",
@@ -147,10 +186,10 @@ const OWNER = {
147
186
  confirm: "watch",
148
187
  scope: "watch",
149
188
  label: "watch",
150
- "archive-fallback": "fetch",
151
- "archive-despite-robots": "fetch",
152
- "sign-key": "fetch discover crawl watch mcp key-directory daemon",
153
- "signature-agent": "fetch discover crawl watch mcp key-directory daemon",
189
+ "archive-fallback": "fetch venue product",
190
+ "archive-despite-robots": "fetch venue product",
191
+ "sign-key": "fetch discover crawl watch mcp key-directory daemon venue product",
192
+ "signature-agent": "fetch discover crawl watch mcp key-directory daemon venue product",
154
193
  ...Object.fromEntries(DAEMON_RUN_OPTIONS.map((option) => [option, "daemon"])),
155
194
  field: "extract",
156
195
  capture: "extract",
@@ -158,7 +197,9 @@ const OWNER = {
158
197
  "embed-model": "find embed mcp",
159
198
  "ollama-url": "find embed mcp",
160
199
  "allow-remote-ollama": "find embed mcp",
161
- "max-docs": "embed"
200
+ "max-docs": "embed",
201
+ ...Object.fromEntries(VENUE_OPTIONS.map((option) => [option, "venue"])),
202
+ "on-behalf-of": "venue product"
162
203
  };
163
204
  /** These are the commands, with the smallest and the largest count of arguments. */
164
205
  const COMMANDS = {
@@ -236,6 +277,16 @@ const COMMANDS = {
236
277
  min: 0,
237
278
  max: 0,
238
279
  wants: "embed takes no argument: give the model with --embedder"
280
+ },
281
+ venue: {
282
+ min: 1,
283
+ max: 1,
284
+ wants: "venue wants the URL of the site of one venue"
285
+ },
286
+ product: {
287
+ min: 1,
288
+ max: 1,
289
+ wants: "product wants the URL of one product page"
239
290
  }
240
291
  };
241
292
  const parse = (args) => {
@@ -692,6 +743,311 @@ const extractCode = (result) => {
692
743
  const done = result.summary.verified + result.summary.normalized;
693
744
  return done === result.summary.fields && done > 0 ? 0 : 1;
694
745
  };
746
+ /** This function reads a latitude or a longitude of the command line. */
747
+ const coordinateOf = (name, text, limit) => {
748
+ const value = /^\s*-?\d+(?:\.\d+)?\s*$/.test(text) ? Number(text) : NaN;
749
+ if (!Number.isFinite(value) || Math.abs(value) > limit) throw new UsageError(`--${name} wants a number from -${String(limit)} to ${String(limit)}, not "${text}"`);
750
+ return value;
751
+ };
752
+ /** This function checks the options of `venue` and `product`, and gives the options of the recipe. */
753
+ const recipeLineOf = (recipe, values, rest, page) => {
754
+ const url = rest[0] ?? "";
755
+ if (!/^https?:\/\//i.test(url) || !URL.canParse(url)) throw new UsageError(`${recipe} wants an http or https URL, not "${url}"`);
756
+ for (const agent of values["on-behalf-of"] ?? []) if (!/^[A-Za-z0-9_-]+$/.test(agent)) throw new UsageError(`--on-behalf-of wants the robots.txt token of an agent, such as Claude-User, not "${agent}"`);
757
+ const name = values.name;
758
+ if (name !== void 0 && name.trim() === "") throw new UsageError("--name wants the name of the venue");
759
+ if (values["require-name"] === true && name === void 0) throw new UsageError("--require-name goes with --name");
760
+ const address = values.address;
761
+ if (address !== void 0 && address.trim() === "") throw new UsageError("--address wants a street address");
762
+ if (values.lat === void 0 !== (values.lon === void 0)) throw new UsageError("--lat and --lon go together");
763
+ const lat = values.lat === void 0 ? void 0 : coordinateOf("lat", values.lat, 90);
764
+ const lon = values.lon === void 0 ? void 0 : coordinateOf("lon", values.lon, 180);
765
+ const osm = values.osm;
766
+ if (osm !== void 0 && osm.trim() === "") throw new UsageError("--osm wants an opening_hours value, such as \"Mo-Fr 08:00-18:00\"");
767
+ const pages = values.pages === void 0 ? void 0 : whole("pages", values.pages, 0, 0);
768
+ if (pages !== void 0 && pages > 8) throw new UsageError(`--pages wants a whole number from 0 to ${String(8)}`);
769
+ const compare = values.compare;
770
+ if (compare !== void 0 && compare.trim() === "") throw new UsageError("--compare wants a file");
771
+ return {
772
+ recipe,
773
+ url,
774
+ render: page.render,
775
+ maxAge: page.maxAge,
776
+ budget: page.budget,
777
+ onBehalfOf: values["on-behalf-of"],
778
+ userProvided: values["user-provided"] === true,
779
+ fallback: values["archive-fallback"] === true ? "archive" : void 0,
780
+ archiveDespiteRobots: values["archive-despite-robots"] === true,
781
+ name,
782
+ requireName: values["require-name"] === true,
783
+ location: address === void 0 && lat === void 0 ? void 0 : {
784
+ ...address === void 0 ? {} : { address },
785
+ ...lat === void 0 ? {} : {
786
+ lat,
787
+ lon
788
+ }
789
+ },
790
+ osm,
791
+ pages,
792
+ compare
793
+ };
794
+ };
795
+ /** The days of a week in their order, as `DAYS` of `hours.ts`. */
796
+ const WEEK_DAYS = [
797
+ "mon",
798
+ "tue",
799
+ "wed",
800
+ "thu",
801
+ "fri",
802
+ "sat",
803
+ "sun"
804
+ ];
805
+ const DAY_LABELS = {
806
+ mon: "Mon",
807
+ tue: "Tue",
808
+ wed: "Wed",
809
+ thu: "Thu",
810
+ fri: "Fri",
811
+ sat: "Sat",
812
+ sun: "Sun"
813
+ };
814
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
815
+ /** This function tells if a value is a time of a kept week: `HH:MM` or `H:MM`, to `24:00`. */
816
+ const isClock = (value) => {
817
+ if (typeof value !== "string") return false;
818
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
819
+ return match !== null && Number(match[1]) <= 24 && Number(match[2]) < 60;
820
+ };
821
+ const intervalOf = (value) => Array.isArray(value) && value.length === 2 && isClock(value[0]) && isClock(value[1]) ? [value[0], value[1]] : void 0;
822
+ /**
823
+ * This function reads the kept week of `--compare`, a JSON file. The file is a week, or an object
824
+ * with the field `week` (a reading of `venue --json`) or `hours_week`. Each day gives a list of
825
+ * intervals (`[]` for closed), one interval, or null for closed. An error names the file.
826
+ */
827
+ const keptWeekOf = (path) => {
828
+ const file = fileOf(path);
829
+ let text;
830
+ try {
831
+ text = readFileSync(file, "utf8");
832
+ } catch (error) {
833
+ const code = error.code;
834
+ throw new UsageError(`--compare: the file ${file} does not open (${typeof code === "string" ? code : "error"})`, { cause: error });
835
+ }
836
+ let data;
837
+ try {
838
+ data = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
839
+ } catch (error) {
840
+ throw new UsageError(`--compare: the file ${file} is not JSON`, { cause: error });
841
+ }
842
+ if (isRecord(data) && (isRecord(data["week"]) || isRecord(data["hours_week"]))) data = isRecord(data["week"]) ? data["week"] : data["hours_week"];
843
+ const wants = `--compare: the file ${file} is not a week. Write a list of intervals, one interval or null for each day, such as {"mon": [["09:00", "17:00"]], "sun": []}`;
844
+ if (!isRecord(data)) throw new UsageError(wants);
845
+ const week = {};
846
+ for (const [key, value] of Object.entries(data)) {
847
+ const day = WEEK_DAYS.find((one) => one === key);
848
+ if (day === void 0) throw new UsageError(`--compare: the file ${file} names "${key}", not a day: use mon, tue, wed, thu, fri, sat or sun`);
849
+ const one = intervalOf(value);
850
+ const intervals = value === null ? [] : one !== void 0 ? [one] : Array.isArray(value) ? value.map(intervalOf) : [void 0];
851
+ if (intervals.some((interval) => interval === void 0)) throw new UsageError(`${wants} (the value of ${key} is not)`);
852
+ week[day] = intervals;
853
+ }
854
+ return {
855
+ file,
856
+ week
857
+ };
858
+ };
859
+ /** This function writes the intervals of one day, as `describeWeek` of `hours.ts` does. */
860
+ const dayText = (intervals) => intervals === void 0 ? "not stated" : intervals.length === 0 ? "closed" : intervals.map(([open, close]) => open === "00:00" && close === "00:00" ? "open 24 hours" : `${open}–${close}`).join(", ");
861
+ /** This function gives the days of a comparison in their order. A day that no week states is not there. */
862
+ const compareDays = (kept, read, comparison) => {
863
+ const days = [];
864
+ for (const day of WEEK_DAYS) {
865
+ const one = {
866
+ day,
867
+ kept: kept[day],
868
+ read: read[day]
869
+ };
870
+ if (one.kept === void 0 && one.read === void 0) continue;
871
+ const status = comparison.status === "new" || comparison.added.includes(day) ? "new" : comparison.days.includes(day) ? "changed" : comparison.skipped.includes(day) ? "skipped" : one.read === void 0 ? "not read" : "same";
872
+ if (comparison.status === "new" && one.read === void 0) continue;
873
+ days.push({
874
+ ...one,
875
+ status
876
+ });
877
+ }
878
+ return days;
879
+ };
880
+ const QUOTE_CHARS = 200;
881
+ const shortText = (text) => text.length > QUOTE_CHARS ? `${text.slice(0, QUOTE_CHARS)}…` : text;
882
+ /** This function gives the lines of one reading: its rank, its source, its week and its quote. */
883
+ const readingLines = (one) => {
884
+ const marks = [
885
+ one.schedule === "other" ? "not the opening hours" : null,
886
+ one.inferred ? "am or pm guessed" : null,
887
+ one.partial ? "a rule left out" : null,
888
+ one.season === null ? null : `season ${one.season}`
889
+ ].filter((mark) => mark !== null);
890
+ const where = [
891
+ one.url,
892
+ one.path,
893
+ one.capture === null ? null : `capture ${one.capture}`,
894
+ one.archived === void 0 ? null : `archived copy (${one.archived.source}, captured ${one.archived.timestamp})`
895
+ ].filter((part) => part !== null);
896
+ return [
897
+ ` rank ${String(one.rank)} ${one.from}${one.label === null ? "" : ` "${one.label}"`} ${one.hours === "" ? "no day" : one.hours}${marks.length > 0 ? ` (${marks.join(", ")})` : ""}`,
898
+ ...where.length > 0 ? [` ${where.join(" · ")}`] : [],
899
+ one.from === "text" ? ` quote: ${JSON.stringify(shortText(one.raw))}` : ` value: ${shortText(one.raw.replace(/\s+/g, " "))}`
900
+ ];
901
+ };
902
+ const pageLine = (page) => {
903
+ const facts = [
904
+ `${page.outcome} ${String(page.status)}`,
905
+ page.from_cache ? "from the store" : null,
906
+ page.rendered ? "rendered" : null,
907
+ page.held_back ? "held back" : null,
908
+ page.archived === void 0 ? null : `archived copy (${page.archived.source})`
909
+ ].filter((fact) => fact !== null);
910
+ return ` ${page.outcome === "ok" || page.outcome === "not-modified" || page.archived !== void 0 ? "✓" : "✗"} ${page.url} ${facts.join(" · ")}`;
911
+ };
912
+ const WALL_OUTCOMES = /* @__PURE__ */ new Set([
913
+ "blocked",
914
+ "challenge",
915
+ "payment-required"
916
+ ]);
917
+ /**
918
+ * This function tells if a site refused the crawler, with the rule of `wall` of `readProduct`. The
919
+ * causes are a 401, a 402 or a 403, a challenge, a bot check, robots.txt or a stop of the host.
920
+ */
921
+ const refusedPage = (page) => WALL_OUTCOMES.has(page.outcome) || page.outcome === "skipped" && /^(?:robots\.txt disallows|host stopped)/.test(page.note ?? "") || page.doc === null && /\bbot check\b/.test(page.note ?? "");
922
+ /**
923
+ * This function gives the exit code of `venue`. It is 0 when the site gave a reading of the opening
924
+ * hours. It is `WALL` when the home page refused the crawler, and `NOTHING_READ` for a different
925
+ * result. An OpenStreetMap value alone does not count.
926
+ */
927
+ const venueCode = (result) => {
928
+ if (result.readings.some((one) => one.from !== "osm" && one.schedule === "opening")) return 0;
929
+ const home = result.pages[0];
930
+ return home !== void 0 && refusedPage(home) ? 4 : 3;
931
+ };
932
+ /** This function gives the exit code of `product`: 0 for Product data, `WALL` for a refusal, else `NOTHING_READ`. */
933
+ const productCode = (result) => result.found ? 0 : result.wall ? 4 : 3;
934
+ /** This line tells how to read a held-back page. */
935
+ const HELD_BACK_LINE = "Add --user-provided if you gave this URL yourself. The crawler logs that read.";
936
+ /** This function gives the line of a page that needs a browser, when rendering is off. */
937
+ const renderLine = (notes, renderOff) => renderOff !== void 0 && notes.some((note) => note !== null && /no browser/i.test(note)) ? [`rendering is off: ${renderOff}`] : [];
938
+ /**
939
+ * This function gives the lines of `venue`: the week of the site, and each reading with its source.
940
+ * Then come the days where the readings disagree, the menus, the phone, the pages and the notes.
941
+ * With `--compare`, the lines give each day of the comparison.
942
+ */
943
+ const describeVenue = (result, compare, renderOff) => {
944
+ const code = venueCode(result);
945
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.home ?? result.url}`];
946
+ const site = result.from_site;
947
+ const home = result.pages[0];
948
+ if (site !== null) {
949
+ const source = [
950
+ `${site.from}, rank ${String(site.rank)}`,
951
+ site.url,
952
+ site.capture === null ? null : `capture ${site.capture}`
953
+ ].filter((part) => part !== null);
954
+ lines.push(` the site: ${site.hours}`, ` from ${source.join(", ")}`);
955
+ } else if (result.best !== null) lines.push(` no reading of the site with no guess. The best reading (${result.best.from}, rank ${String(result.best.rank)}): ${result.best.hours}`);
956
+ else if (code === 4 && home !== void 0) lines.push(` wall: the site refused the crawler (${home.outcome} ${String(home.status)}). The crawler does not try a different identity.`);
957
+ else lines.push(" no opening hours found");
958
+ if (result.readings.length > 0) {
959
+ lines.push("readings, the best first:");
960
+ for (const one of result.readings) lines.push(...readingLines(one));
961
+ }
962
+ for (const day of result.disagree) {
963
+ const said = result.readings.filter((one) => one.rank <= 2 && one.week[day] !== void 0).map((one) => `${dayText(one.week[day])} (rank ${String(one.rank)} ${one.from})`);
964
+ lines.push(`the readings disagree on ${DAY_LABELS[day]}: ${said.join(" · ")}`);
965
+ }
966
+ if (result.menus.length > 0) lines.push("menus:", ...result.menus.map((menu) => ` ${menu.url}${menu.text === "" ? "" : ` ${menu.text}`}`));
967
+ if (result.phone !== null) lines.push(`phone: ${result.phone}`);
968
+ if (result.moved !== null) lines.push(`moved: the site now goes to ${result.moved}`);
969
+ if (result.names_venue !== null) lines.push(result.names_venue ? "names the venue: yes" : "names the venue: no. The domain can belong to a different business now.");
970
+ if (result.held_back) lines.push(`held back: a page asked not to be AI input, so the recipe read nothing from it. ${HELD_BACK_LINE}`);
971
+ if (result.pages.length > 0) lines.push(`pages (${String(result.pages.length)}):`, ...result.pages.map(pageLine));
972
+ if (compare !== void 0) {
973
+ const comparison = compare.comparison;
974
+ if (comparison === null || site === null) lines.push(`compare with ${compare.file}: no reading of the site with no guess, so nothing to compare`);
975
+ else {
976
+ const changed = comparison.days.length > 0 ? ` (${comparison.days.map((day) => DAY_LABELS[day]).join(", ")})` : "";
977
+ lines.push(`compare with ${compare.file}: ${comparison.status}${changed}`);
978
+ for (const one of compareDays(compare.kept, site.week, comparison)) {
979
+ const text = one.status === "changed" || one.status === "skipped" ? `${dayText(one.kept)} → ${dayText(one.read)}` : one.status === "not read" ? `kept ${dayText(one.kept)}` : dayText(one.read);
980
+ lines.push(` ${DAY_LABELS[one.day]} ${one.status.padEnd(8)} ${text}`);
981
+ }
982
+ }
983
+ }
984
+ if (result.note !== null) lines.push(`note: ${result.note}`);
985
+ lines.push(...renderLine([result.note], renderOff));
986
+ return lines;
987
+ };
988
+ /** This function gives the number of digits after the decimal point of a currency: 2 for EUR, 0 for JPY. */
989
+ const fractionOf = (currency) => {
990
+ try {
991
+ return new Intl.NumberFormat("en", {
992
+ style: "currency",
993
+ currency
994
+ }).resolvedOptions().maximumFractionDigits ?? 2;
995
+ } catch {
996
+ return 2;
997
+ }
998
+ };
999
+ /** This function writes an amount of money with its ISO 4217 code: `1,299.00 EUR`, `980 JPY`. */
1000
+ const moneyText = (money) => {
1001
+ const digits = fractionOf(money.currency);
1002
+ return `${new Intl.NumberFormat("en", {
1003
+ minimumFractionDigits: digits,
1004
+ maximumFractionDigits: Math.max(digits, 4)
1005
+ }).format(money.amount)} ${money.currency}`;
1006
+ };
1007
+ /**
1008
+ * This function gives the lines of `product`. They are the name, the brand and the codes, the price
1009
+ * as money, the availability, the variant, the seller, the page and the notes. A wall and a
1010
+ * held-back page say so.
1011
+ */
1012
+ const describeProduct = (result, renderOff) => {
1013
+ const code = productCode(result);
1014
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.name ?? result.url}`];
1015
+ if (result.name !== null) lines.push(` ${result.url}`);
1016
+ if (code === 4) lines.push(` wall: the shop refused the crawler (${result.outcome} ${String(result.status)}). The crawler does not try a different identity.`);
1017
+ if (result.held_back) lines.push(` held back: the page asked not to be AI input. ${HELD_BACK_LINE}`);
1018
+ const codes = [
1019
+ result.brand === null ? null : `brand ${result.brand}`,
1020
+ result.sku === null ? null : `sku ${result.sku}`,
1021
+ result.gtin === null ? null : `gtin ${result.gtin}`
1022
+ ].filter((part) => part !== null);
1023
+ if (codes.length > 0) lines.push(` ${codes.join(" · ")}`);
1024
+ if (result.price !== null) lines.push(result.price_to === null ? ` price ${moneyText(result.price)}` : ` price ${moneyText(result.price)} to ${moneyText(result.price_to)}, the range of the offers`);
1025
+ if (result.availability !== null) lines.push(` availability ${result.availability}${result.availabilities.length > 1 ? ` (the offers: ${result.availabilities.join(", ")})` : ""}`);
1026
+ const variant = result.variant;
1027
+ if (variant !== null) {
1028
+ const parts = [
1029
+ variant.name,
1030
+ variant.sku === null ? null : `sku ${variant.sku}`,
1031
+ variant.price === null ? null : moneyText(variant.price),
1032
+ variant.availability
1033
+ ].filter((part) => part !== null);
1034
+ lines.push(` variant ${parts.length > 0 ? parts.join(" · ") : "with no data"}`);
1035
+ }
1036
+ if (result.seller !== null) lines.push(` seller ${result.seller}`);
1037
+ if (result.image !== null) lines.push(` image ${result.image}`);
1038
+ if (result.archived !== void 0) lines.push(` archived copy (${result.archived.source}, captured ${result.archived.timestamp}): ${result.archived.url}`);
1039
+ const facts = [
1040
+ `${result.outcome} ${String(result.status)}`,
1041
+ result.doc === null ? null : `doc ${result.doc}`,
1042
+ result.capture === null ? null : `capture ${result.capture}`,
1043
+ result.rendered ? "rendered" : null,
1044
+ result.from_cache ? "from the store" : null
1045
+ ].filter((fact) => fact !== null);
1046
+ lines.push(` ${facts.join(" · ")}`);
1047
+ if (result.note !== null) lines.push(` note: ${result.note}`);
1048
+ lines.push(...renderLine([result.note], renderOff).map((line) => ` ${line}`));
1049
+ return lines;
1050
+ };
695
1051
  /** This function checks the options of `export`, and gives its format and its options. */
696
1052
  const exportPlan = (values) => {
697
1053
  const out = values.out;
@@ -780,6 +1136,11 @@ const readLine = (args, env) => {
780
1136
  } catch (error) {
781
1137
  throw new UsageError(`--max-age: ${error.message}`, { cause: error });
782
1138
  }
1139
+ const recipeLine = command === "venue" || command === "product" ? recipeLineOf(command, values, rest, {
1140
+ render,
1141
+ maxAge,
1142
+ budget: values.budget === void 0 ? void 0 : crawlLimits.budgetMs
1143
+ }) : void 0;
783
1144
  return {
784
1145
  kind: "command",
785
1146
  command,
@@ -806,7 +1167,8 @@ const readLine = (args, env) => {
806
1167
  signKey,
807
1168
  signatureAgent,
808
1169
  embedder,
809
- maxDocs
1170
+ maxDocs,
1171
+ recipeLine
810
1172
  };
811
1173
  };
812
1174
  //#endregion
@@ -827,7 +1189,7 @@ Usage:
827
1189
  polite-crawler read <doc> print a range of a stored document
828
1190
  polite-crawler verify <doc> <quote> check that a quote is in a stored document
829
1191
  polite-crawler reindex rebuild the search index from the capture log
830
- polite-crawler mcp serve the tools over MCP (standard input and output)
1192
+ polite-crawler mcp start the MCP server of polite-crawler-mcp (deprecated)
831
1193
  polite-crawler export --out <file> write the stored captures as a WARC or WACZ archive
832
1194
  polite-crawler discover <site> list the pages of a site from its sitemaps and feeds
833
1195
  polite-crawler crawl <url...> fetch a site and follow its links, within a budget
@@ -843,6 +1205,8 @@ Usage:
843
1205
  polite-crawler extract <doc> --field name=value[:kind]...
844
1206
  check proposed field values against a stored page
845
1207
  polite-crawler embed --embedder <name> give vectors to the stored pages, for find --rank hybrid
1208
+ polite-crawler venue <url> read the opening hours of a venue from its own site
1209
+ polite-crawler product <url> read the schema.org Product data of a product page
846
1210
 
847
1211
  Options:
848
1212
  --dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
@@ -937,6 +1301,20 @@ Options:
937
1301
  embed --budget <time> the longest time (default 10m); no embedding starts after it
938
1302
  --max-docs <n> how many texts get vectors at most
939
1303
  --kind <kind> only this kind of document
1304
+ venue --name <text> the name of the venue: the result tells if the home page names it
1305
+ --require-name with --name: read no hours from a site that does not name the venue
1306
+ --address <text> the street address, for a page that lists more than one location
1307
+ --lat <n> --lon <n> the position of the venue, for the same use (within 150 m);
1308
+ write a value below 0 after =, such as --lon=-20.5
1309
+ --osm <value> an OpenStreetMap opening_hours value, read as one more reading;
1310
+ no request goes to OpenStreetMap
1311
+ --pages <n> the pages after the home page: 0 to 8 (default 3)
1312
+ --compare <file> compare the week of the site with a kept week (JSON), day by day
1313
+ venue, product --render, --max-age, --archive-fallback, --archive-despite-robots as for fetch
1314
+ --budget <time> no request starts after this time, such as 2m (default: no limit)
1315
+ --on-behalf-of <agent> the robots.txt rules of this agent apply too, such as
1316
+ Claude-User (repeat it)
1317
+ --user-provided as for read
940
1318
 
941
1319
  Exit codes:
942
1320
  0 done
@@ -946,19 +1324,24 @@ Exit codes:
946
1324
  or an error
947
1325
  (fetch: a page with an archived copy counts as done)
948
1326
  2 the command line is wrong
1327
+ 3 venue, product: nothing read: no opening hours of the site or no Product data, a page
1328
+ held back, or a page that did not come
1329
+ 4 venue, product: the site refused the crawler (a wall): a 401, a 402 or a 403, a
1330
+ challenge, a bot check, robots.txt or a stopped host. The crawler does not try a
1331
+ different identity
949
1332
  `;
950
1333
  /**
951
1334
  * This function does the command `export`, and gives the exit code. `readLine` checked the whole
952
1335
  * command line first. The export only reads the store: it makes no store, and it sends no request.
953
1336
  */
954
1337
  const exportCommand = async (plan, dir, json) => {
955
- const result = plan.format === "wacz" ? await (await import("./wacz-BFNG42Kt.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-CVS0t1fW.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
1338
+ const result = plan.format === "wacz" ? await (await import("./wacz-DKk2HBmx.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-C_ew76bV.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
956
1339
  process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
957
1340
  return 0;
958
1341
  };
959
1342
  /** This function prints the key directory of a key file, and tells where to host it. */
960
1343
  const keyDirectoryCommand = async (key, agent) => {
961
- const { KEY_DIRECTORY_PATH, KEY_DIRECTORY_TYPE, keyDirectory } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
1344
+ const { KEY_DIRECTORY_PATH, KEY_DIRECTORY_TYPE, keyDirectory } = await import("./webbotauth-Dq_dA_u3.mjs").then((n) => n.s);
962
1345
  let directory;
963
1346
  try {
964
1347
  directory = keyDirectory([key]);
@@ -1074,7 +1457,7 @@ const runWatchCommand = async (crawler, line, values, limits, json, out) => {
1074
1457
  };
1075
1458
  /** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
1076
1459
  const describeDiff = async (diff) => {
1077
- const { formatDiff } = await import("./diff-NQcQqKe9.mjs").then((n) => n.i);
1460
+ const { formatDiff } = await import("./diff-CpKN0Jdc.mjs").then((n) => n.i);
1078
1461
  return [
1079
1462
  `${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
1080
1463
  formatDiff(diff),
@@ -1084,7 +1467,7 @@ const describeDiff = async (diff) => {
1084
1467
  };
1085
1468
  /** This function checks the fields against a stored document, and prints a line for each field. The exit code is 0 when each field is verified or normalized. */
1086
1469
  const runExtract = async (crawler, doc, fields, options, json, out) => {
1087
- const { extractDocument } = await import("./extract-ByFfc8Zk.mjs").then((n) => n.i);
1470
+ const { extractDocument } = await import("./extract-DqeqiNHb.mjs").then((n) => n.i);
1088
1471
  const claims = fields;
1089
1472
  const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
1090
1473
  if (json) out(JSON.stringify(result, null, 2));
@@ -1098,14 +1481,14 @@ const runExtract = async (crawler, doc, fields, options, json, out) => {
1098
1481
  const embedderFor = async (choice, dir) => {
1099
1482
  try {
1100
1483
  if (choice.kind === "ollama") {
1101
- const { ollamaEmbedder } = await import("./ollama-BJxkjny4.mjs").then((n) => n.a);
1484
+ const { ollamaEmbedder } = await import("./ollama-D1xRh3hY.mjs").then((n) => n.a);
1102
1485
  return ollamaEmbedder({
1103
1486
  ...choice.model === void 0 ? {} : { model: choice.model },
1104
1487
  ...choice.url === void 0 ? {} : { baseUrl: choice.url },
1105
1488
  allowRemote: choice.allowRemote
1106
1489
  });
1107
1490
  }
1108
- const { transformersEmbedder } = await import("./transformers-BQMlaGj4.mjs").then((n) => n.i);
1491
+ const { transformersEmbedder } = await import("./transformers-BNiuiLxT.mjs").then((n) => n.i);
1109
1492
  return transformersEmbedder({
1110
1493
  ...choice.model === void 0 ? {} : { model: choice.model },
1111
1494
  cacheDir: join(dir, "models")
@@ -1149,7 +1532,7 @@ const main = async (args) => {
1149
1532
  if (command === "key-directory") return keyDirectoryCommand(keyFile(signKey), signatureAgent);
1150
1533
  let signer;
1151
1534
  if (signKey !== void 0 && signatureAgent !== void 0) {
1152
- const { createWebBotAuthSigner } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
1535
+ const { createWebBotAuthSigner } = await import("./webbotauth-Dq_dA_u3.mjs").then((n) => n.s);
1153
1536
  const privateKey = keyFile(signKey);
1154
1537
  try {
1155
1538
  signer = createWebBotAuthSigner({
@@ -1170,15 +1553,24 @@ const main = async (args) => {
1170
1553
  ...embedder === void 0 ? {} : { embedder }
1171
1554
  };
1172
1555
  if (command === "mcp") {
1173
- const { startMcpServer } = await import("./mcp.mjs");
1174
- await startMcpServer({
1556
+ const { McpPackageMissing, loadMcpPackage } = await import("./mcp-package-Y0bREnOS.mjs");
1557
+ let mcp;
1558
+ try {
1559
+ mcp = await loadMcpPackage();
1560
+ } catch (error) {
1561
+ if (!(error instanceof McpPackageMissing)) throw error;
1562
+ process.stderr.write(`polite-crawler: ${error.message}\n`);
1563
+ return 2;
1564
+ }
1565
+ process.stderr.write("polite-crawler: the command mcp is deprecated, and 0.3.0 removes it. Start npx polite-crawler-mcp.\n");
1566
+ await mcp.startMcpServer({
1175
1567
  ...options,
1176
1568
  allowUserProvided: line.allowUserProvided
1177
1569
  });
1178
1570
  return 0;
1179
1571
  }
1180
1572
  if (command === "daemon") {
1181
- const { daemonCommand } = await import("./cli-daemon--cTmq0Z5.mjs");
1573
+ const { daemonCommand } = await import("./cli-daemon-qS4PH2bM.mjs");
1182
1574
  return daemonCommand(line.daemonLine, options, {
1183
1575
  allowUserProvided: line.allowUserProvided,
1184
1576
  json: values.json === true,
@@ -1186,12 +1578,15 @@ const main = async (args) => {
1186
1578
  });
1187
1579
  }
1188
1580
  if (line.exportPlan !== void 0) return exportCommand(line.exportPlan, dir, values.json === true);
1189
- const { openCrawler } = await import("./open-C4fhXJgD.mjs").then((n) => n.a);
1581
+ const recipeLine = line.recipeLine;
1582
+ const recipes = recipeLine === void 0 ? void 0 : await import("./cli-recipes-qbLRVThF.mjs");
1583
+ const recipeInput = recipes === void 0 || recipeLine === void 0 ? void 0 : recipes.prepareRecipe(recipeLine);
1584
+ const { openCrawler } = await import("./open-CEo22Hpu.mjs").then((n) => n.a);
1190
1585
  let renderOff;
1191
1586
  let renderer = options.renderer;
1192
- if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
1587
+ if (!(command === "fetch" || command === "crawl" || recipeLine !== void 0 || watchLine?.action === "run") || render === "never") renderer = false;
1193
1588
  else if (renderer === void 0) {
1194
- const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
1589
+ const { browserMissing } = await import("./browser-56dBBLec.mjs").then((n) => n.n);
1195
1590
  renderOff = await browserMissing();
1196
1591
  if (renderOff !== void 0) renderer = false;
1197
1592
  }
@@ -1273,6 +1668,15 @@ const main = async (args) => {
1273
1668
  maxDocs: line.maxDocs,
1274
1669
  kind: values.kind
1275
1670
  }, values.json === true, out);
1671
+ case "venue":
1672
+ case "product": {
1673
+ const io = {
1674
+ json: values.json === true,
1675
+ out,
1676
+ renderOff
1677
+ };
1678
+ return command === "venue" ? await recipes.venueCommand(crawler, recipeLine, recipeInput, io) : await recipes.productCommand(crawler, recipeLine, io);
1679
+ }
1276
1680
  default: {
1277
1681
  const counts = await crawler.reindex();
1278
1682
  print(counts, () => describeReindex(counts));
@@ -1292,6 +1696,6 @@ main(process.argv.slice(2)).then((code) => {
1292
1696
  process.exitCode = usage ? 2 : 1;
1293
1697
  });
1294
1698
  //#endregion
1295
- export { describeDaemonStatus as t };
1699
+ export { keptWeekOf as a, describeVenue as i, describeDaemonStatus as n, productCode as o, describeProduct as r, venueCode as s, UsageError as t };
1296
1700
 
1297
1701
  //# sourceMappingURL=cli.mjs.map