polite-crawler 0.1.3 → 0.1.4

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 (36) hide show
  1. package/README.md +22 -0
  2. package/build/{cli-daemon--cTmq0Z5.mjs → cli-daemon-ZYRdy6EW.mjs} +3 -3
  3. package/build/{cli-daemon--cTmq0Z5.mjs.map → cli-daemon-ZYRdy6EW.mjs.map} +1 -1
  4. package/build/cli-recipes-Bci6HREA.mjs +59 -0
  5. package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
  6. package/build/cli.mjs +397 -18
  7. package/build/cli.mjs.map +1 -1
  8. package/build/{daemon-fZTE24P5.mjs → daemon-DK6U51_v.mjs} +3 -3
  9. package/build/{daemon-fZTE24P5.mjs.map → daemon-DK6U51_v.mjs.map} +1 -1
  10. package/build/{daemon-DP4EqjFL.d.mts → daemon-wwIWsUmU.d.mts} +7 -2
  11. package/build/{diff-NQcQqKe9.mjs → diff-BUM91Rev.mjs} +2 -2
  12. package/build/{diff-NQcQqKe9.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
  13. package/build/{extract-ByFfc8Zk.mjs → extract-CefB5Vfn.mjs} +2 -2
  14. package/build/{extract-ByFfc8Zk.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
  15. package/build/index.d.mts +2 -2
  16. package/build/index.mjs +10 -894
  17. package/build/mcp.d.mts +1 -1
  18. package/build/mcp.mjs +4 -4
  19. package/build/{open-C4fhXJgD.mjs → open-CbpSCEZc.mjs} +12 -284
  20. package/build/open-CbpSCEZc.mjs.map +1 -0
  21. package/build/recipe-GiFQojmo.mjs +279 -0
  22. package/build/recipe-GiFQojmo.mjs.map +1 -0
  23. package/build/recipe-product-Dellqudu.mjs +892 -0
  24. package/build/recipe-product-Dellqudu.mjs.map +1 -0
  25. package/build/{runtime-JToRO63o.mjs → runtime-D_r2SYge.mjs} +2 -2
  26. package/build/{runtime-JToRO63o.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
  27. package/build/{version-ntbY-6wh.mjs → version-BPem-XZd.mjs} +2 -2
  28. package/build/{version-ntbY-6wh.mjs.map → version-BPem-XZd.mjs.map} +1 -1
  29. package/build/{wacz-BFNG42Kt.mjs → wacz-QyzLp4mC.mjs} +3 -3
  30. package/build/{wacz-BFNG42Kt.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
  31. package/build/{warc-CVS0t1fW.mjs → warc-BHcaRKHw.mjs} +2 -2
  32. package/build/{warc-CVS0t1fW.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
  33. package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
  34. package/package.json +1 -1
  35. package/build/index.mjs.map +0 -1
  36. package/build/open-C4fhXJgD.mjs.map +0 -1
package/build/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as durationMs, r as VERSION } from "./version-ntbY-6wh.mjs";
2
+ import { i as durationMs, r as VERSION } from "./version-BPem-XZd.mjs";
3
3
  import { t as isLoopbackHost } from "./loopback-C_QYowSf.mjs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { readFileSync } from "node:fs";
@@ -103,6 +103,18 @@ const OPTIONS = {
103
103
  "ollama-url": { type: "string" },
104
104
  "allow-remote-ollama": { type: "boolean" },
105
105
  "max-docs": { type: "string" },
106
+ name: { type: "string" },
107
+ "require-name": { type: "boolean" },
108
+ address: { type: "string" },
109
+ lat: { type: "string" },
110
+ lon: { type: "string" },
111
+ osm: { type: "string" },
112
+ pages: { type: "string" },
113
+ compare: { type: "string" },
114
+ "on-behalf-of": {
115
+ type: "string",
116
+ multiple: true
117
+ },
106
118
  ...EXPORT_OPTIONS
107
119
  };
108
120
  /** These options belong to `daemon`, and not to `daemon status`. */
@@ -120,23 +132,34 @@ const DAEMON_RUN_OPTIONS = [
120
132
  "mcp-token-file",
121
133
  "allow-remote"
122
134
  ];
135
+ /** These options belong to `venue` only. */
136
+ const VENUE_OPTIONS = [
137
+ "name",
138
+ "require-name",
139
+ "address",
140
+ "lat",
141
+ "lon",
142
+ "osm",
143
+ "pages",
144
+ "compare"
145
+ ];
123
146
  /** These options belong to one command, or to the commands that the text names. */
124
147
  const OWNER = {
125
- render: "fetch crawl watch",
126
- "max-age": "fetch crawl discover",
148
+ render: "fetch crawl watch venue product",
149
+ "max-age": "fetch crawl discover venue product",
127
150
  limit: "find",
128
151
  kind: "find embed",
129
152
  rank: "find",
130
153
  from: "read diff",
131
154
  length: "read",
132
- "user-provided": "read extract",
155
+ "user-provided": "read extract venue product",
133
156
  "allow-user-provided": "mcp daemon",
134
157
  ...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
135
158
  "max-urls": "discover",
136
159
  feed: "discover",
137
160
  "max-pages": "crawl watch",
138
161
  "max-depth": "crawl",
139
- budget: "crawl watch embed",
162
+ budget: "crawl watch embed venue product",
140
163
  include: "crawl",
141
164
  exclude: "crawl",
142
165
  sitemaps: "crawl",
@@ -147,10 +170,10 @@ const OWNER = {
147
170
  confirm: "watch",
148
171
  scope: "watch",
149
172
  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",
173
+ "archive-fallback": "fetch venue product",
174
+ "archive-despite-robots": "fetch venue product",
175
+ "sign-key": "fetch discover crawl watch mcp key-directory daemon venue product",
176
+ "signature-agent": "fetch discover crawl watch mcp key-directory daemon venue product",
154
177
  ...Object.fromEntries(DAEMON_RUN_OPTIONS.map((option) => [option, "daemon"])),
155
178
  field: "extract",
156
179
  capture: "extract",
@@ -158,7 +181,9 @@ const OWNER = {
158
181
  "embed-model": "find embed mcp",
159
182
  "ollama-url": "find embed mcp",
160
183
  "allow-remote-ollama": "find embed mcp",
161
- "max-docs": "embed"
184
+ "max-docs": "embed",
185
+ ...Object.fromEntries(VENUE_OPTIONS.map((option) => [option, "venue"])),
186
+ "on-behalf-of": "venue product"
162
187
  };
163
188
  /** These are the commands, with the smallest and the largest count of arguments. */
164
189
  const COMMANDS = {
@@ -236,6 +261,16 @@ const COMMANDS = {
236
261
  min: 0,
237
262
  max: 0,
238
263
  wants: "embed takes no argument: give the model with --embedder"
264
+ },
265
+ venue: {
266
+ min: 1,
267
+ max: 1,
268
+ wants: "venue wants the URL of the site of one venue"
269
+ },
270
+ product: {
271
+ min: 1,
272
+ max: 1,
273
+ wants: "product wants the URL of one product page"
239
274
  }
240
275
  };
241
276
  const parse = (args) => {
@@ -692,6 +727,311 @@ const extractCode = (result) => {
692
727
  const done = result.summary.verified + result.summary.normalized;
693
728
  return done === result.summary.fields && done > 0 ? 0 : 1;
694
729
  };
730
+ /** This function reads a latitude or a longitude of the command line. */
731
+ const coordinateOf = (name, text, limit) => {
732
+ const value = /^\s*-?\d+(?:\.\d+)?\s*$/.test(text) ? Number(text) : NaN;
733
+ if (!Number.isFinite(value) || Math.abs(value) > limit) throw new UsageError(`--${name} wants a number from -${String(limit)} to ${String(limit)}, not "${text}"`);
734
+ return value;
735
+ };
736
+ /** This function checks the options of `venue` and `product`, and gives the options of the recipe. */
737
+ const recipeLineOf = (recipe, values, rest, page) => {
738
+ const url = rest[0] ?? "";
739
+ if (!/^https?:\/\//i.test(url) || !URL.canParse(url)) throw new UsageError(`${recipe} wants an http or https URL, not "${url}"`);
740
+ 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}"`);
741
+ const name = values.name;
742
+ if (name !== void 0 && name.trim() === "") throw new UsageError("--name wants the name of the venue");
743
+ if (values["require-name"] === true && name === void 0) throw new UsageError("--require-name goes with --name");
744
+ const address = values.address;
745
+ if (address !== void 0 && address.trim() === "") throw new UsageError("--address wants a street address");
746
+ if (values.lat === void 0 !== (values.lon === void 0)) throw new UsageError("--lat and --lon go together");
747
+ const lat = values.lat === void 0 ? void 0 : coordinateOf("lat", values.lat, 90);
748
+ const lon = values.lon === void 0 ? void 0 : coordinateOf("lon", values.lon, 180);
749
+ const osm = values.osm;
750
+ if (osm !== void 0 && osm.trim() === "") throw new UsageError("--osm wants an opening_hours value, such as \"Mo-Fr 08:00-18:00\"");
751
+ const pages = values.pages === void 0 ? void 0 : whole("pages", values.pages, 0, 0);
752
+ if (pages !== void 0 && pages > 8) throw new UsageError(`--pages wants a whole number from 0 to ${String(8)}`);
753
+ const compare = values.compare;
754
+ if (compare !== void 0 && compare.trim() === "") throw new UsageError("--compare wants a file");
755
+ return {
756
+ recipe,
757
+ url,
758
+ render: page.render,
759
+ maxAge: page.maxAge,
760
+ budget: page.budget,
761
+ onBehalfOf: values["on-behalf-of"],
762
+ userProvided: values["user-provided"] === true,
763
+ fallback: values["archive-fallback"] === true ? "archive" : void 0,
764
+ archiveDespiteRobots: values["archive-despite-robots"] === true,
765
+ name,
766
+ requireName: values["require-name"] === true,
767
+ location: address === void 0 && lat === void 0 ? void 0 : {
768
+ ...address === void 0 ? {} : { address },
769
+ ...lat === void 0 ? {} : {
770
+ lat,
771
+ lon
772
+ }
773
+ },
774
+ osm,
775
+ pages,
776
+ compare
777
+ };
778
+ };
779
+ /** The days of a week in their order, as `DAYS` of `hours.ts`. */
780
+ const WEEK_DAYS = [
781
+ "mon",
782
+ "tue",
783
+ "wed",
784
+ "thu",
785
+ "fri",
786
+ "sat",
787
+ "sun"
788
+ ];
789
+ const DAY_LABELS = {
790
+ mon: "Mon",
791
+ tue: "Tue",
792
+ wed: "Wed",
793
+ thu: "Thu",
794
+ fri: "Fri",
795
+ sat: "Sat",
796
+ sun: "Sun"
797
+ };
798
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
799
+ /** This function tells if a value is a time of a kept week: `HH:MM` or `H:MM`, to `24:00`. */
800
+ const isClock = (value) => {
801
+ if (typeof value !== "string") return false;
802
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
803
+ return match !== null && Number(match[1]) <= 24 && Number(match[2]) < 60;
804
+ };
805
+ const intervalOf = (value) => Array.isArray(value) && value.length === 2 && isClock(value[0]) && isClock(value[1]) ? [value[0], value[1]] : void 0;
806
+ /**
807
+ * This function reads the kept week of `--compare`, a JSON file. The file is a week, or an object
808
+ * with the field `week` (a reading of `venue --json`) or `hours_week`. Each day gives a list of
809
+ * intervals (`[]` for closed), one interval, or null for closed. An error names the file.
810
+ */
811
+ const keptWeekOf = (path) => {
812
+ const file = fileOf(path);
813
+ let text;
814
+ try {
815
+ text = readFileSync(file, "utf8");
816
+ } catch (error) {
817
+ const code = error.code;
818
+ throw new UsageError(`--compare: the file ${file} does not open (${typeof code === "string" ? code : "error"})`, { cause: error });
819
+ }
820
+ let data;
821
+ try {
822
+ data = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
823
+ } catch (error) {
824
+ throw new UsageError(`--compare: the file ${file} is not JSON`, { cause: error });
825
+ }
826
+ if (isRecord(data) && (isRecord(data["week"]) || isRecord(data["hours_week"]))) data = isRecord(data["week"]) ? data["week"] : data["hours_week"];
827
+ 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": []}`;
828
+ if (!isRecord(data)) throw new UsageError(wants);
829
+ const week = {};
830
+ for (const [key, value] of Object.entries(data)) {
831
+ const day = WEEK_DAYS.find((one) => one === key);
832
+ 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`);
833
+ const one = intervalOf(value);
834
+ const intervals = value === null ? [] : one !== void 0 ? [one] : Array.isArray(value) ? value.map(intervalOf) : [void 0];
835
+ if (intervals.some((interval) => interval === void 0)) throw new UsageError(`${wants} (the value of ${key} is not)`);
836
+ week[day] = intervals;
837
+ }
838
+ return {
839
+ file,
840
+ week
841
+ };
842
+ };
843
+ /** This function writes the intervals of one day, as `describeWeek` of `hours.ts` does. */
844
+ 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(", ");
845
+ /** This function gives the days of a comparison in their order. A day that no week states is not there. */
846
+ const compareDays = (kept, read, comparison) => {
847
+ const days = [];
848
+ for (const day of WEEK_DAYS) {
849
+ const one = {
850
+ day,
851
+ kept: kept[day],
852
+ read: read[day]
853
+ };
854
+ if (one.kept === void 0 && one.read === void 0) continue;
855
+ 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";
856
+ if (comparison.status === "new" && one.read === void 0) continue;
857
+ days.push({
858
+ ...one,
859
+ status
860
+ });
861
+ }
862
+ return days;
863
+ };
864
+ const QUOTE_CHARS = 200;
865
+ const shortText = (text) => text.length > QUOTE_CHARS ? `${text.slice(0, QUOTE_CHARS)}…` : text;
866
+ /** This function gives the lines of one reading: its rank, its source, its week and its quote. */
867
+ const readingLines = (one) => {
868
+ const marks = [
869
+ one.schedule === "other" ? "not the opening hours" : null,
870
+ one.inferred ? "am or pm guessed" : null,
871
+ one.partial ? "a rule left out" : null,
872
+ one.season === null ? null : `season ${one.season}`
873
+ ].filter((mark) => mark !== null);
874
+ const where = [
875
+ one.url,
876
+ one.path,
877
+ one.capture === null ? null : `capture ${one.capture}`,
878
+ one.archived === void 0 ? null : `archived copy (${one.archived.source}, captured ${one.archived.timestamp})`
879
+ ].filter((part) => part !== null);
880
+ return [
881
+ ` rank ${String(one.rank)} ${one.from}${one.label === null ? "" : ` "${one.label}"`} ${one.hours === "" ? "no day" : one.hours}${marks.length > 0 ? ` (${marks.join(", ")})` : ""}`,
882
+ ...where.length > 0 ? [` ${where.join(" · ")}`] : [],
883
+ one.from === "text" ? ` quote: ${JSON.stringify(shortText(one.raw))}` : ` value: ${shortText(one.raw.replace(/\s+/g, " "))}`
884
+ ];
885
+ };
886
+ const pageLine = (page) => {
887
+ const facts = [
888
+ `${page.outcome} ${String(page.status)}`,
889
+ page.from_cache ? "from the store" : null,
890
+ page.rendered ? "rendered" : null,
891
+ page.held_back ? "held back" : null,
892
+ page.archived === void 0 ? null : `archived copy (${page.archived.source})`
893
+ ].filter((fact) => fact !== null);
894
+ return ` ${page.outcome === "ok" || page.outcome === "not-modified" || page.archived !== void 0 ? "✓" : "✗"} ${page.url} ${facts.join(" · ")}`;
895
+ };
896
+ const WALL_OUTCOMES = /* @__PURE__ */ new Set([
897
+ "blocked",
898
+ "challenge",
899
+ "payment-required"
900
+ ]);
901
+ /**
902
+ * This function tells if a site refused the crawler, with the rule of `wall` of `readProduct`. The
903
+ * causes are a 401, a 402 or a 403, a challenge, a bot check, robots.txt or a stop of the host.
904
+ */
905
+ 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 ?? "");
906
+ /**
907
+ * This function gives the exit code of `venue`. It is 0 when the site gave a reading of the opening
908
+ * hours. It is `WALL` when the home page refused the crawler, and `NOTHING_READ` for a different
909
+ * result. An OpenStreetMap value alone does not count.
910
+ */
911
+ const venueCode = (result) => {
912
+ if (result.readings.some((one) => one.from !== "osm" && one.schedule === "opening")) return 0;
913
+ const home = result.pages[0];
914
+ return home !== void 0 && refusedPage(home) ? 4 : 3;
915
+ };
916
+ /** This function gives the exit code of `product`: 0 for Product data, `WALL` for a refusal, else `NOTHING_READ`. */
917
+ const productCode = (result) => result.found ? 0 : result.wall ? 4 : 3;
918
+ /** This line tells how to read a held-back page. */
919
+ const HELD_BACK_LINE = "Add --user-provided if you gave this URL yourself. The crawler logs that read.";
920
+ /** This function gives the line of a page that needs a browser, when rendering is off. */
921
+ const renderLine = (notes, renderOff) => renderOff !== void 0 && notes.some((note) => note !== null && /no browser/i.test(note)) ? [`rendering is off: ${renderOff}`] : [];
922
+ /**
923
+ * This function gives the lines of `venue`: the week of the site, and each reading with its source.
924
+ * Then come the days where the readings disagree, the menus, the phone, the pages and the notes.
925
+ * With `--compare`, the lines give each day of the comparison.
926
+ */
927
+ const describeVenue = (result, compare, renderOff) => {
928
+ const code = venueCode(result);
929
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.home ?? result.url}`];
930
+ const site = result.from_site;
931
+ const home = result.pages[0];
932
+ if (site !== null) {
933
+ const source = [
934
+ `${site.from}, rank ${String(site.rank)}`,
935
+ site.url,
936
+ site.capture === null ? null : `capture ${site.capture}`
937
+ ].filter((part) => part !== null);
938
+ lines.push(` the site: ${site.hours}`, ` from ${source.join(", ")}`);
939
+ } 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}`);
940
+ 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.`);
941
+ else lines.push(" no opening hours found");
942
+ if (result.readings.length > 0) {
943
+ lines.push("readings, the best first:");
944
+ for (const one of result.readings) lines.push(...readingLines(one));
945
+ }
946
+ for (const day of result.disagree) {
947
+ 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})`);
948
+ lines.push(`the readings disagree on ${DAY_LABELS[day]}: ${said.join(" · ")}`);
949
+ }
950
+ if (result.menus.length > 0) lines.push("menus:", ...result.menus.map((menu) => ` ${menu.url}${menu.text === "" ? "" : ` ${menu.text}`}`));
951
+ if (result.phone !== null) lines.push(`phone: ${result.phone}`);
952
+ if (result.moved !== null) lines.push(`moved: the site now goes to ${result.moved}`);
953
+ 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.");
954
+ 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}`);
955
+ if (result.pages.length > 0) lines.push(`pages (${String(result.pages.length)}):`, ...result.pages.map(pageLine));
956
+ if (compare !== void 0) {
957
+ const comparison = compare.comparison;
958
+ if (comparison === null || site === null) lines.push(`compare with ${compare.file}: no reading of the site with no guess, so nothing to compare`);
959
+ else {
960
+ const changed = comparison.days.length > 0 ? ` (${comparison.days.map((day) => DAY_LABELS[day]).join(", ")})` : "";
961
+ lines.push(`compare with ${compare.file}: ${comparison.status}${changed}`);
962
+ for (const one of compareDays(compare.kept, site.week, comparison)) {
963
+ 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);
964
+ lines.push(` ${DAY_LABELS[one.day]} ${one.status.padEnd(8)} ${text}`);
965
+ }
966
+ }
967
+ }
968
+ if (result.note !== null) lines.push(`note: ${result.note}`);
969
+ lines.push(...renderLine([result.note], renderOff));
970
+ return lines;
971
+ };
972
+ /** This function gives the number of digits after the decimal point of a currency: 2 for EUR, 0 for JPY. */
973
+ const fractionOf = (currency) => {
974
+ try {
975
+ return new Intl.NumberFormat("en", {
976
+ style: "currency",
977
+ currency
978
+ }).resolvedOptions().maximumFractionDigits ?? 2;
979
+ } catch {
980
+ return 2;
981
+ }
982
+ };
983
+ /** This function writes an amount of money with its ISO 4217 code: `1,299.00 EUR`, `980 JPY`. */
984
+ const moneyText = (money) => {
985
+ const digits = fractionOf(money.currency);
986
+ return `${new Intl.NumberFormat("en", {
987
+ minimumFractionDigits: digits,
988
+ maximumFractionDigits: Math.max(digits, 4)
989
+ }).format(money.amount)} ${money.currency}`;
990
+ };
991
+ /**
992
+ * This function gives the lines of `product`. They are the name, the brand and the codes, the price
993
+ * as money, the availability, the variant, the seller, the page and the notes. A wall and a
994
+ * held-back page say so.
995
+ */
996
+ const describeProduct = (result, renderOff) => {
997
+ const code = productCode(result);
998
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.name ?? result.url}`];
999
+ if (result.name !== null) lines.push(` ${result.url}`);
1000
+ if (code === 4) lines.push(` wall: the shop refused the crawler (${result.outcome} ${String(result.status)}). The crawler does not try a different identity.`);
1001
+ if (result.held_back) lines.push(` held back: the page asked not to be AI input. ${HELD_BACK_LINE}`);
1002
+ const codes = [
1003
+ result.brand === null ? null : `brand ${result.brand}`,
1004
+ result.sku === null ? null : `sku ${result.sku}`,
1005
+ result.gtin === null ? null : `gtin ${result.gtin}`
1006
+ ].filter((part) => part !== null);
1007
+ if (codes.length > 0) lines.push(` ${codes.join(" · ")}`);
1008
+ 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`);
1009
+ if (result.availability !== null) lines.push(` availability ${result.availability}${result.availabilities.length > 1 ? ` (the offers: ${result.availabilities.join(", ")})` : ""}`);
1010
+ const variant = result.variant;
1011
+ if (variant !== null) {
1012
+ const parts = [
1013
+ variant.name,
1014
+ variant.sku === null ? null : `sku ${variant.sku}`,
1015
+ variant.price === null ? null : moneyText(variant.price),
1016
+ variant.availability
1017
+ ].filter((part) => part !== null);
1018
+ lines.push(` variant ${parts.length > 0 ? parts.join(" · ") : "with no data"}`);
1019
+ }
1020
+ if (result.seller !== null) lines.push(` seller ${result.seller}`);
1021
+ if (result.image !== null) lines.push(` image ${result.image}`);
1022
+ if (result.archived !== void 0) lines.push(` archived copy (${result.archived.source}, captured ${result.archived.timestamp}): ${result.archived.url}`);
1023
+ const facts = [
1024
+ `${result.outcome} ${String(result.status)}`,
1025
+ result.doc === null ? null : `doc ${result.doc}`,
1026
+ result.capture === null ? null : `capture ${result.capture}`,
1027
+ result.rendered ? "rendered" : null,
1028
+ result.from_cache ? "from the store" : null
1029
+ ].filter((fact) => fact !== null);
1030
+ lines.push(` ${facts.join(" · ")}`);
1031
+ if (result.note !== null) lines.push(` note: ${result.note}`);
1032
+ lines.push(...renderLine([result.note], renderOff).map((line) => ` ${line}`));
1033
+ return lines;
1034
+ };
695
1035
  /** This function checks the options of `export`, and gives its format and its options. */
696
1036
  const exportPlan = (values) => {
697
1037
  const out = values.out;
@@ -780,6 +1120,11 @@ const readLine = (args, env) => {
780
1120
  } catch (error) {
781
1121
  throw new UsageError(`--max-age: ${error.message}`, { cause: error });
782
1122
  }
1123
+ const recipeLine = command === "venue" || command === "product" ? recipeLineOf(command, values, rest, {
1124
+ render,
1125
+ maxAge,
1126
+ budget: values.budget === void 0 ? void 0 : crawlLimits.budgetMs
1127
+ }) : void 0;
783
1128
  return {
784
1129
  kind: "command",
785
1130
  command,
@@ -806,7 +1151,8 @@ const readLine = (args, env) => {
806
1151
  signKey,
807
1152
  signatureAgent,
808
1153
  embedder,
809
- maxDocs
1154
+ maxDocs,
1155
+ recipeLine
810
1156
  };
811
1157
  };
812
1158
  //#endregion
@@ -843,6 +1189,8 @@ Usage:
843
1189
  polite-crawler extract <doc> --field name=value[:kind]...
844
1190
  check proposed field values against a stored page
845
1191
  polite-crawler embed --embedder <name> give vectors to the stored pages, for find --rank hybrid
1192
+ polite-crawler venue <url> read the opening hours of a venue from its own site
1193
+ polite-crawler product <url> read the schema.org Product data of a product page
846
1194
 
847
1195
  Options:
848
1196
  --dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
@@ -937,6 +1285,20 @@ Options:
937
1285
  embed --budget <time> the longest time (default 10m); no embedding starts after it
938
1286
  --max-docs <n> how many texts get vectors at most
939
1287
  --kind <kind> only this kind of document
1288
+ venue --name <text> the name of the venue: the result tells if the home page names it
1289
+ --require-name with --name: read no hours from a site that does not name the venue
1290
+ --address <text> the street address, for a page that lists more than one location
1291
+ --lat <n> --lon <n> the position of the venue, for the same use (within 150 m);
1292
+ write a value below 0 after =, such as --lon=-20.5
1293
+ --osm <value> an OpenStreetMap opening_hours value, read as one more reading;
1294
+ no request goes to OpenStreetMap
1295
+ --pages <n> the pages after the home page: 0 to 8 (default 3)
1296
+ --compare <file> compare the week of the site with a kept week (JSON), day by day
1297
+ venue, product --render, --max-age, --archive-fallback, --archive-despite-robots as for fetch
1298
+ --budget <time> no request starts after this time, such as 2m (default: no limit)
1299
+ --on-behalf-of <agent> the robots.txt rules of this agent apply too, such as
1300
+ Claude-User (repeat it)
1301
+ --user-provided as for read
940
1302
 
941
1303
  Exit codes:
942
1304
  0 done
@@ -946,13 +1308,18 @@ Exit codes:
946
1308
  or an error
947
1309
  (fetch: a page with an archived copy counts as done)
948
1310
  2 the command line is wrong
1311
+ 3 venue, product: nothing read: no opening hours of the site or no Product data, a page
1312
+ held back, or a page that did not come
1313
+ 4 venue, product: the site refused the crawler (a wall): a 401, a 402 or a 403, a
1314
+ challenge, a bot check, robots.txt or a stopped host. The crawler does not try a
1315
+ different identity
949
1316
  `;
950
1317
  /**
951
1318
  * This function does the command `export`, and gives the exit code. `readLine` checked the whole
952
1319
  * command line first. The export only reads the store: it makes no store, and it sends no request.
953
1320
  */
954
1321
  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);
1322
+ const result = plan.format === "wacz" ? await (await import("./wacz-QyzLp4mC.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-BHcaRKHw.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
956
1323
  process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
957
1324
  return 0;
958
1325
  };
@@ -1074,7 +1441,7 @@ const runWatchCommand = async (crawler, line, values, limits, json, out) => {
1074
1441
  };
1075
1442
  /** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
1076
1443
  const describeDiff = async (diff) => {
1077
- const { formatDiff } = await import("./diff-NQcQqKe9.mjs").then((n) => n.i);
1444
+ const { formatDiff } = await import("./diff-BUM91Rev.mjs").then((n) => n.i);
1078
1445
  return [
1079
1446
  `${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
1080
1447
  formatDiff(diff),
@@ -1084,7 +1451,7 @@ const describeDiff = async (diff) => {
1084
1451
  };
1085
1452
  /** 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
1453
  const runExtract = async (crawler, doc, fields, options, json, out) => {
1087
- const { extractDocument } = await import("./extract-ByFfc8Zk.mjs").then((n) => n.i);
1454
+ const { extractDocument } = await import("./extract-CefB5Vfn.mjs").then((n) => n.i);
1088
1455
  const claims = fields;
1089
1456
  const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
1090
1457
  if (json) out(JSON.stringify(result, null, 2));
@@ -1178,7 +1545,7 @@ const main = async (args) => {
1178
1545
  return 0;
1179
1546
  }
1180
1547
  if (command === "daemon") {
1181
- const { daemonCommand } = await import("./cli-daemon--cTmq0Z5.mjs");
1548
+ const { daemonCommand } = await import("./cli-daemon-ZYRdy6EW.mjs");
1182
1549
  return daemonCommand(line.daemonLine, options, {
1183
1550
  allowUserProvided: line.allowUserProvided,
1184
1551
  json: values.json === true,
@@ -1186,10 +1553,13 @@ const main = async (args) => {
1186
1553
  });
1187
1554
  }
1188
1555
  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);
1556
+ const recipeLine = line.recipeLine;
1557
+ const recipes = recipeLine === void 0 ? void 0 : await import("./cli-recipes-Bci6HREA.mjs");
1558
+ const recipeInput = recipes === void 0 || recipeLine === void 0 ? void 0 : recipes.prepareRecipe(recipeLine);
1559
+ const { openCrawler } = await import("./open-CbpSCEZc.mjs").then((n) => n.a);
1190
1560
  let renderOff;
1191
1561
  let renderer = options.renderer;
1192
- if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
1562
+ if (!(command === "fetch" || command === "crawl" || recipeLine !== void 0 || watchLine?.action === "run") || render === "never") renderer = false;
1193
1563
  else if (renderer === void 0) {
1194
1564
  const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
1195
1565
  renderOff = await browserMissing();
@@ -1273,6 +1643,15 @@ const main = async (args) => {
1273
1643
  maxDocs: line.maxDocs,
1274
1644
  kind: values.kind
1275
1645
  }, values.json === true, out);
1646
+ case "venue":
1647
+ case "product": {
1648
+ const io = {
1649
+ json: values.json === true,
1650
+ out,
1651
+ renderOff
1652
+ };
1653
+ return command === "venue" ? await recipes.venueCommand(crawler, recipeLine, recipeInput, io) : await recipes.productCommand(crawler, recipeLine, io);
1654
+ }
1276
1655
  default: {
1277
1656
  const counts = await crawler.reindex();
1278
1657
  print(counts, () => describeReindex(counts));
@@ -1292,6 +1671,6 @@ main(process.argv.slice(2)).then((code) => {
1292
1671
  process.exitCode = usage ? 2 : 1;
1293
1672
  });
1294
1673
  //#endregion
1295
- export { describeDaemonStatus as t };
1674
+ export { keptWeekOf as a, describeVenue as i, describeDaemonStatus as n, productCode as o, describeProduct as r, venueCode as s, UsageError as t };
1296
1675
 
1297
1676
  //# sourceMappingURL=cli.mjs.map