mcp-scraper 0.44.3 → 0.45.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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/api-server.cjs +4129 -3715
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +2 -2
  5. package/dist/bin/mcp-scraper-cli.cjs +1 -1
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs +2619 -2507
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +3 -3
  14. package/dist/chunk-CEZ3NFBP.js +7 -0
  15. package/dist/chunk-CEZ3NFBP.js.map +1 -0
  16. package/dist/{chunk-2SE4GYCS.js → chunk-FQIICRR4.js} +2849 -2741
  17. package/dist/chunk-FQIICRR4.js.map +1 -0
  18. package/dist/{chunk-WWIJ2NID.js → chunk-JFOT5JEL.js} +2 -2
  19. package/dist/{chunk-X5WDJ7D7.js → chunk-SOEYDWJU.js} +46 -3
  20. package/dist/chunk-SOEYDWJU.js.map +1 -0
  21. package/dist/{extract-bundle-U3MNYDKC.js → extract-bundle-DYMQRQWG.js} +3 -3
  22. package/dist/{server-JJLFY46K.js → server-7B75SDNG.js} +388 -135
  23. package/dist/server-7B75SDNG.js.map +1 -0
  24. package/dist/{site-extract-repository-CKCJDP7N.js → site-extract-repository-G723PZIL.js} +3 -3
  25. package/dist/{worker-73AYU4IK.js → worker-4LZGRHDA.js} +2 -2
  26. package/package.json +1 -1
  27. package/dist/chunk-2SE4GYCS.js.map +0 -1
  28. package/dist/chunk-32Z52OE4.js +0 -7
  29. package/dist/chunk-32Z52OE4.js.map +0 -1
  30. package/dist/chunk-X5WDJ7D7.js.map +0 -1
  31. package/dist/server-JJLFY46K.js.map +0 -1
  32. /package/dist/{chunk-WWIJ2NID.js.map → chunk-JFOT5JEL.js.map} +0 -0
  33. /package/dist/{extract-bundle-U3MNYDKC.js.map → extract-bundle-DYMQRQWG.js.map} +0 -0
  34. /package/dist/{site-extract-repository-CKCJDP7N.js.map → site-extract-repository-G723PZIL.js.map} +0 -0
  35. /package/dist/{worker-73AYU4IK.js.map → worker-4LZGRHDA.js.map} +0 -0
@@ -56,6 +56,7 @@ import {
56
56
  HttpMcpToolExecutor,
57
57
  InternalScheduledArtifactRenderRequestSchema,
58
58
  ListScheduledRunsQuerySchema,
59
+ LocalSourcebookSchemaTypeSchema,
59
60
  PublicScheduledRunTokenSchema,
60
61
  RenewEditorialReadingRoomDownloadInputSchema,
61
62
  SCHEDULED_ARTIFACT_TEMPLATE_PRESET,
@@ -72,11 +73,13 @@ import {
72
73
  configureReportSaving,
73
74
  createConnectedDataArtifact,
74
75
  createDirectoryCsvArtifact,
76
+ defaultLocalSourcebookSchemaType,
75
77
  exportFanout,
76
78
  handleMcpTasksHttpRequest,
77
79
  harvestTimeoutBudget,
78
80
  hashOwnerId,
79
81
  isLoopbackBaseUrl,
82
+ localSourcebookSchemaContract,
80
83
  outputBaseDir,
81
84
  registerBrowserAgentMcpTools,
82
85
  registerMemoryMcpTools,
@@ -85,8 +88,9 @@ import {
85
88
  renewConnectedDataArtifactDownload,
86
89
  renewDirectoryArtifactDownload,
87
90
  resolveDeploymentProfile,
91
+ resolveLocalSourcebookSchemaType,
88
92
  transcribeMediaUrl
89
- } from "./chunk-2SE4GYCS.js";
93
+ } from "./chunk-FQIICRR4.js";
90
94
  import {
91
95
  auditImageUrls,
92
96
  auditImages,
@@ -142,7 +146,7 @@ import {
142
146
  } from "./chunk-3ZUBQQPQ.js";
143
147
  import {
144
148
  PACKAGE_VERSION
145
- } from "./chunk-32Z52OE4.js";
149
+ } from "./chunk-CEZ3NFBP.js";
146
150
  import {
147
151
  abandonExtractSettlement,
148
152
  countSuccessfulPages,
@@ -165,7 +169,7 @@ import {
165
169
  setExtractJobTotal,
166
170
  settleExtractJob,
167
171
  terminalExtractJobStatus
168
- } from "./chunk-WWIJ2NID.js";
172
+ } from "./chunk-JFOT5JEL.js";
169
173
  import {
170
174
  BROWSER_OPEN_MIN_BALANCE_MC,
171
175
  CONCURRENCY_PRICE_ID,
@@ -178,6 +182,7 @@ import {
178
182
  CONNECTED_USAGE_RATE_POLICY,
179
183
  CONNECTED_USAGE_RATE_POLICY_VERSION,
180
184
  CREDIT_COST_CATALOG,
185
+ LISTING_WRITE_RATE_POLICY,
181
186
  LedgerOperation,
182
187
  MC_COSTS,
183
188
  MC_PER_CREDIT,
@@ -213,7 +218,7 @@ import {
213
218
  tierChangeLeavesPrivateOffer,
214
219
  tierCreditsPerInterval,
215
220
  vendorCostUsdToMc
216
- } from "./chunk-X5WDJ7D7.js";
221
+ } from "./chunk-SOEYDWJU.js";
217
222
  import {
218
223
  CaptchaError,
219
224
  MapsSearchIncompletePageError,
@@ -8667,7 +8672,7 @@ var siteExtractFn = inngest.createFunction(
8667
8672
  });
8668
8673
  }) : null;
8669
8674
  const finalized = await step.run("finalize", async () => {
8670
- const { assembleExtractArtifacts } = await import("./extract-bundle-U3MNYDKC.js");
8675
+ const { assembleExtractArtifacts } = await import("./extract-bundle-DYMQRQWG.js");
8671
8676
  const current = await getExtractJob(jobId2);
8672
8677
  if (!current) throw new Error("extract job disappeared before finalization");
8673
8678
  const stored = await assembleExtractArtifacts(current, { branding, imageAudit });
@@ -12033,16 +12038,18 @@ async function createLocalSourcebookSubmission(input) {
12033
12038
  const existing = await db.execute({ sql: `SELECT * FROM local_sourcebook_submissions WHERE owner_user_id = ? AND idempotency_key = ? LIMIT 1`, args: [input.ownerUserId, input.idempotencyKey] });
12034
12039
  if (existing.rows[0]) {
12035
12040
  const submission = mapSubmission(existing.rows[0]);
12036
- if (submission.category !== input.category || submission.state !== input.state || submission.slug !== input.slug || submission.businessName !== input.businessName || submission.websiteUrl !== input.websiteUrl) throw new Error("Local Sourcebook idempotency key conflicts with a different listing identity.");
12041
+ if (submission.category !== input.category || submission.state !== input.state || submission.slug !== input.slug || submission.businessName !== input.businessName || submission.websiteUrl !== input.websiteUrl || input.schemaOrgType != null && submission.coverage.schemaOrgType !== input.schemaOrgType) throw new Error("Local Sourcebook idempotency key conflicts with a different listing identity.");
12037
12042
  return submission;
12038
12043
  }
12039
12044
  }
12040
12045
  const id = `lsb_${randomUUID3().replace(/-/g, "")}`;
12041
12046
  const coverage = {
12042
- requested: ["website_crawl", "services_products", "service_areas", "genuine_images", "staff_team", "review_sources"],
12047
+ requested: ["website_crawl", "structured_data", "services_products", "service_areas", "genuine_images", "staff_team", "review_sources"],
12043
12048
  crawl: { state: "queued", pagesDiscovered: 0, pagesCaptured: 0 },
12044
12049
  media: { state: "queued", genuineImagesCaptured: 0 },
12045
- reviews: { state: "queued", captured: 0, availableReported: null, stopReason: null }
12050
+ reviews: { state: "queued", captured: 0, availableReported: null, stopReason: null },
12051
+ ...input.schemaOrgType ? { schemaOrgType: input.schemaOrgType } : {},
12052
+ ...input.billing ? { billing: { mode: "fixed_listing_write", ...input.billing } } : {}
12046
12053
  };
12047
12054
  await db.batch([
12048
12055
  { sql: `INSERT INTO local_sourcebook_submissions (id, owner_user_id, category, state, slug, business_name, website_url, coverage_json, idempotency_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, args: [id, input.ownerUserId, input.category, input.state, input.slug, input.businessName, input.websiteUrl, JSON.stringify(coverage), input.idempotencyKey ?? null] },
@@ -12062,13 +12069,17 @@ async function getLocalSourcebookDraft(id, ownerUserId) {
12062
12069
  const result = await getDb().execute({ sql: `SELECT payload_json FROM local_sourcebook_revisions WHERE submission_id = ? AND revision = ?`, args: [id, submission.draftRevision] });
12063
12070
  return result.rows[0] ? parseJson2(result.rows[0].payload_json) : null;
12064
12071
  }
12065
- async function queueLocalSourcebookRefresh(id, ownerUserId) {
12072
+ async function queueLocalSourcebookRefresh(id, ownerUserId, billing) {
12066
12073
  const existing = await getLocalSourcebookSubmission(id, ownerUserId);
12067
12074
  if (!existing) return null;
12068
12075
  if (existing.status === "rejected" || existing.status === "unpublished") {
12069
12076
  throw new Error(`A ${existing.status} Local Sourcebook listing cannot be refreshed by its owner.`);
12070
12077
  }
12071
- const coverage = { ...existing.coverage, refreshRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
12078
+ const coverage = {
12079
+ ...existing.coverage,
12080
+ refreshRequestedAt: (/* @__PURE__ */ new Date()).toISOString(),
12081
+ ...billing ? { billing: { mode: "fixed_listing_write", ...billing } } : {}
12082
+ };
12072
12083
  await getDb().execute({ sql: `UPDATE local_sourcebook_submissions SET status = 'queued', coverage_json = ?, acquisition_claimed_at = NULL, acquisition_error = NULL, updated_at = datetime('now') WHERE id = ? AND owner_user_id = ?`, args: [JSON.stringify(coverage), id, ownerUserId] });
12073
12084
  await event(id, "enrichment.queued", "owner", String(ownerUserId));
12074
12085
  return getLocalSourcebookSubmission(id, ownerUserId);
@@ -12774,17 +12785,100 @@ var MapsExtractor = class {
12774
12785
  }
12775
12786
  };
12776
12787
 
12788
+ // src/api/listing-write-billing.ts
12789
+ function operationFor(surface, kind) {
12790
+ if (surface === "local_sourcebook") {
12791
+ return kind === "create" ? LedgerOperation.LOCAL_SOURCEBOOK_CREATE : LedgerOperation.LOCAL_SOURCEBOOK_EDIT;
12792
+ }
12793
+ return kind === "create" ? LedgerOperation.COMMONS_CREATE : LedgerOperation.COMMONS_EDIT;
12794
+ }
12795
+ function refundOperationFor(surface, kind) {
12796
+ if (surface === "local_sourcebook") {
12797
+ return kind === "create" ? LedgerOperation.LOCAL_SOURCEBOOK_CREATE_REFUND : LedgerOperation.LOCAL_SOURCEBOOK_EDIT_REFUND;
12798
+ }
12799
+ return kind === "create" ? LedgerOperation.COMMONS_CREATE_REFUND : LedgerOperation.COMMONS_EDIT_REFUND;
12800
+ }
12801
+ function listingWritePrice(kind) {
12802
+ return LISTING_WRITE_RATE_POLICY[kind];
12803
+ }
12804
+ function listingWriteDebitKey(surface, userId, idempotencyKey3) {
12805
+ return `listing-write:${surface}:${userId}:${idempotencyKey3}`;
12806
+ }
12807
+ function publicListingWriteBilling(receipt) {
12808
+ return {
12809
+ surface: receipt.surface,
12810
+ operation: receipt.kind,
12811
+ credits: receipt.credits,
12812
+ charged: !receipt.duplicate,
12813
+ idempotentReplay: receipt.duplicate,
12814
+ balanceCredits: receipt.balanceMc / MC_PER_CREDIT,
12815
+ acquisitionIncluded: receipt.surface === "local_sourcebook"
12816
+ };
12817
+ }
12818
+ async function chargeListingWrite(input) {
12819
+ const price = listingWritePrice(input.kind);
12820
+ const debitKey2 = listingWriteDebitKey(input.surface, input.userId, input.idempotencyKey);
12821
+ const debit = await debitMcIdempotent(
12822
+ input.userId,
12823
+ price.mc,
12824
+ operationFor(input.surface, input.kind),
12825
+ `${input.surface}:${input.kind}`,
12826
+ debitKey2
12827
+ );
12828
+ if (!debit.ok) return { insufficient: insufficientBalanceResponse(debit.balance_mc, price.mc) };
12829
+ return {
12830
+ surface: input.surface,
12831
+ kind: input.kind,
12832
+ userId: input.userId,
12833
+ credits: price.credits,
12834
+ amountMc: price.mc,
12835
+ debitKey: debitKey2,
12836
+ duplicate: debit.duplicate,
12837
+ balanceMc: debit.balance_mc
12838
+ };
12839
+ }
12840
+ async function refundListingWrite(receipt, reason) {
12841
+ if (receipt.duplicate) return null;
12842
+ return settleDebitMcIdempotent(
12843
+ receipt.userId,
12844
+ receipt.debitKey,
12845
+ 0,
12846
+ refundOperationFor(receipt.surface, receipt.kind),
12847
+ reason,
12848
+ receipt.surface
12849
+ );
12850
+ }
12851
+ async function refundPersistedListingWrite(input) {
12852
+ return settleDebitMcIdempotent(
12853
+ input.userId,
12854
+ input.debitKey,
12855
+ 0,
12856
+ refundOperationFor(input.surface, input.kind),
12857
+ input.reason,
12858
+ input.surface
12859
+ );
12860
+ }
12861
+
12777
12862
  // src/api/local-sourcebook-compiler.ts
12778
12863
  import { createHash as createHash3 } from "crypto";
12779
12864
 
12780
12865
  // src/api/local-sourcebook-public-urls.ts
12781
12866
  var DEFAULT_ROOT_DOMAIN = "localsourcebook.com";
12867
+ var CATEGORY_PUBLIC_SLUGS = {
12868
+ home: "home-services",
12869
+ professional: "professional",
12870
+ restaurants: "restaurants",
12871
+ financial: "financial",
12872
+ realestate: "realestate",
12873
+ auto: "auto",
12874
+ wellness: "wellness"
12875
+ };
12782
12876
  function rootDomain() {
12783
12877
  const configured = process.env.LOCAL_SOURCEBOOK_PUBLIC_ROOT_DOMAIN?.trim() || DEFAULT_ROOT_DOMAIN;
12784
12878
  return configured.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").replace(/\.+$/, "").toLowerCase();
12785
12879
  }
12786
12880
  function localSourcebookPublicUrls(category, state, slug2) {
12787
- const categoryUrl = `https://${category}.${rootDomain()}`;
12881
+ const categoryUrl = `https://${CATEGORY_PUBLIC_SLUGS[category]}.${rootDomain()}`;
12788
12882
  const statePath = `/${state.toLowerCase()}`;
12789
12883
  const profilePath = `${statePath}/${slug2}/`;
12790
12884
  const reviewsPath = `${statePath}/${slug2}/reviews`;
@@ -13091,6 +13185,65 @@ function reviewStopReason(input, captured, exposed) {
13091
13185
  if (exposed && captured < exposed) return `Google Maps stopped exposing additional complete review cards after ${captured} of ${exposed} reported reviews.`;
13092
13186
  return `All ${captured} complete, unique review cards exposed during this acquisition were retained.`;
13093
13187
  }
13188
+ function buildLocalBusinessJsonLd(input) {
13189
+ const businessId = `${input.profileUrl}#business`;
13190
+ const business = {
13191
+ "@type": input.schemaOrgType === "LocalBusiness" ? "LocalBusiness" : ["LocalBusiness", input.schemaOrgType],
13192
+ "@id": businessId,
13193
+ url: input.profileUrl,
13194
+ name: input.name,
13195
+ description: input.description,
13196
+ ...input.phone ? { telephone: input.phone } : {},
13197
+ ...input.address || input.locality || input.region || input.postalCode ? {
13198
+ address: {
13199
+ "@type": "PostalAddress",
13200
+ ...input.address ? { streetAddress: input.address } : {},
13201
+ ...input.locality ? { addressLocality: input.locality } : {},
13202
+ ...input.region ? { addressRegion: input.region } : {},
13203
+ ...input.postalCode ? { postalCode: input.postalCode } : {},
13204
+ addressCountry: "US"
13205
+ }
13206
+ } : {},
13207
+ ...Number.isFinite(input.latitude) && Number.isFinite(input.longitude) ? {
13208
+ geo: { "@type": "GeoCoordinates", latitude: input.latitude, longitude: input.longitude }
13209
+ } : {},
13210
+ ...input.images.length ? { image: input.images } : {},
13211
+ ...input.serviceAreas.length ? { areaServed: input.serviceAreas } : {},
13212
+ ...input.services.length ? {
13213
+ hasOfferCatalog: {
13214
+ "@type": "OfferCatalog",
13215
+ name: "Services",
13216
+ itemListElement: input.services.map((name) => ({ "@type": "Offer", itemOffered: { "@type": "Service", name } }))
13217
+ }
13218
+ } : {},
13219
+ ...Number.isFinite(input.rating) && Number.isFinite(input.ratingCount) && Number(input.ratingCount) > 0 ? {
13220
+ aggregateRating: { "@type": "AggregateRating", ratingValue: input.rating, reviewCount: input.ratingCount, bestRating: 5, worstRating: 1 }
13221
+ } : {},
13222
+ sameAs: unique([input.website, input.mapsUrl], 10)
13223
+ };
13224
+ const graph = [business, {
13225
+ "@type": "BreadcrumbList",
13226
+ "@id": `${input.profileUrl}#breadcrumbs`,
13227
+ itemListElement: [
13228
+ { "@type": "ListItem", position: 1, name: "Local Sourcebook", item: "https://localsourcebook.com/" },
13229
+ { "@type": "ListItem", position: 2, name: input.region || "State directory", item: input.stateUrl },
13230
+ { "@type": "ListItem", position: 3, name: input.name, item: input.profileUrl }
13231
+ ]
13232
+ }];
13233
+ if (input.faq.length) {
13234
+ graph.push({
13235
+ "@type": "FAQPage",
13236
+ "@id": `${input.profileUrl}#faq`,
13237
+ about: { "@id": businessId },
13238
+ mainEntity: input.faq.map((item) => ({
13239
+ "@type": "Question",
13240
+ name: item.question,
13241
+ acceptedAnswer: { "@type": "Answer", text: item.answer }
13242
+ }))
13243
+ });
13244
+ }
13245
+ return { "@context": "https://schema.org", "@graph": graph };
13246
+ }
13094
13247
  function compileLocalSourcebookListing(input) {
13095
13248
  const collectedAt = input.collectedAt ?? (/* @__PURE__ */ new Date()).toISOString();
13096
13249
  const pages = successfulPages(input.site);
@@ -13160,6 +13313,33 @@ function compileLocalSourcebookListing(input) {
13160
13313
  }
13161
13314
  ];
13162
13315
  const publicUrls = localSourcebookPublicUrls(input.submission.category, input.submission.state, input.submission.slug);
13316
+ const schemaOrgType = resolveLocalSourcebookSchemaType(input.submission.category, input.submission.coverage.schemaOrgType);
13317
+ const listingName = maps?.name || input.submission.businessName;
13318
+ const listingWebsite = maps?.website || input.submission.websiteUrl;
13319
+ const listingMapsUrl = maps?.placeUrl || maps?.cidUrl || void 0;
13320
+ const stateUrl = new URL(`/${input.submission.state.toLowerCase()}`, publicUrls.profileUrl).href;
13321
+ const jsonLd = buildLocalBusinessJsonLd({
13322
+ schemaOrgType,
13323
+ profileUrl: publicUrls.profileUrl,
13324
+ stateUrl,
13325
+ name: listingName,
13326
+ description: summary,
13327
+ address: location2.address,
13328
+ locality: location2.locality,
13329
+ region: location2.region,
13330
+ postalCode: location2.postalCode,
13331
+ phone: maps?.phoneDisplay || maps?.phone || void 0,
13332
+ website: listingWebsite,
13333
+ mapsUrl: listingMapsUrl,
13334
+ latitude: maps?.lat ?? void 0,
13335
+ longitude: maps?.lng ?? void 0,
13336
+ services,
13337
+ serviceAreas,
13338
+ images: media.map((item) => String(item.src)),
13339
+ rating: Number.isFinite(rating) ? rating : void 0,
13340
+ ratingCount: Number.isFinite(exposedCount) ? exposedCount : void 0,
13341
+ faq
13342
+ });
13163
13343
  const listing = {
13164
13344
  id: input.submission.id,
13165
13345
  publicationRevision: input.submission.draftRevision + 1,
@@ -13176,7 +13356,9 @@ function compileLocalSourcebookListing(input) {
13176
13356
  profile: publicUrls.profileUrl,
13177
13357
  reviews: publicUrls.reviewsUrl
13178
13358
  },
13179
- name: maps?.name || input.submission.businessName,
13359
+ name: listingName,
13360
+ schemaOrgType,
13361
+ jsonLd,
13180
13362
  summary,
13181
13363
  description,
13182
13364
  address: location2.address,
@@ -13184,8 +13366,8 @@ function compileLocalSourcebookListing(input) {
13184
13366
  region: location2.region,
13185
13367
  postalCode: location2.postalCode,
13186
13368
  phone: maps?.phoneDisplay || maps?.phone || void 0,
13187
- website: maps?.website || input.submission.websiteUrl,
13188
- mapsUrl: maps?.placeUrl || maps?.cidUrl || void 0,
13369
+ website: listingWebsite,
13370
+ mapsUrl: listingMapsUrl,
13189
13371
  latitude: maps?.lat ?? void 0,
13190
13372
  longitude: maps?.lng ?? void 0,
13191
13373
  hours,
@@ -13225,11 +13407,12 @@ function compileLocalSourcebookListing(input) {
13225
13407
  return {
13226
13408
  listing,
13227
13409
  coverage: {
13228
- requested: ["website_crawl", "services_products", "service_areas", "genuine_images", "staff_team", "review_sources"],
13410
+ requested: ["website_crawl", "structured_data", "services_products", "service_areas", "genuine_images", "staff_team", "review_sources"],
13229
13411
  crawl: { state: input.siteError ? "failed" : input.site?.truncated ? "partial" : "complete", pagesDiscovered: input.site?.spider.totalFound ?? 0, pagesCaptured: pages.length, requestedMaximum: input.requestedMaxPages, error: input.siteError ?? null },
13230
13412
  media: { state: input.siteError ? "failed" : "complete", genuineImagesCaptured: media.length, teamImagesCaptured: media.filter((item) => item.kind === "team").length },
13231
13413
  reviews: { state: input.mapsError || input.reviewBillingLimited ? "partial" : "complete", captured: reviews.length, availableReported: Number.isFinite(exposedCount) ? exposedCount : null, requestedMaximum: input.requestedMaxReviews, stopReason: reviewStopReason(input, reviews.length, Number.isFinite(exposedCount) ? exposedCount : void 0) },
13232
13414
  maps: { state: maps ? "complete" : "failed", exactPlaceUrl: maps?.placeUrl ?? null, cid: maps?.cidDecimal ?? maps?.cid ?? null, error: input.mapsError ?? null },
13415
+ structuredData: { state: "complete", rootType: "LocalBusiness", schemaOrgType, graphNodes: jsonLd["@graph"].length },
13233
13416
  compiledAt: collectedAt
13234
13417
  }
13235
13418
  };
@@ -13314,110 +13497,83 @@ async function runLocalSourcebookAcquisition(submission, provider = createDefaul
13314
13497
  const user = await getUserById(submission.ownerUserId);
13315
13498
  if (!user) throw new Error("The submission owner is no longer active.");
13316
13499
  if (!user.subscription_tier) throw new Error("An active MCP Scraper subscription is required for acquisition.");
13317
- const billingCycle = `local-sourcebook:${submission.id}:revision:${submission.draftRevision}`;
13318
- const mapsDescription = `Local Sourcebook: ${submission.businessName}`;
13319
- const mapsHold = await debitMcIdempotent(
13320
- submission.ownerUserId,
13321
- MC_COSTS.maps_place,
13322
- LedgerOperation.MAPS_PLACE,
13323
- mapsDescription,
13324
- `${billingCycle}:maps-place`
13325
- );
13326
- if (!mapsHold.ok) throw new Error(`Insufficient credits: Local Sourcebook needs at least ${MC_COSTS.maps_place / 100} credits for exact-place acquisition before website pages and reviews.`);
13327
- const balanceAfterMapsHold = mapsHold.balance_mc;
13328
- const maxPages = Math.min(LOCAL_SOURCEBOOK_MAX_PAGES, Math.floor(balanceAfterMapsHold / MC_COSTS.page_scrape));
13500
+ const storedBilling = submission.coverage.billing;
13501
+ const inferredKind = submission.publishedRevision == null ? "create" : "edit";
13502
+ const kind = storedBilling?.kind === "edit" ? "edit" : storedBilling?.kind === "create" ? "create" : inferredKind;
13503
+ const price = listingWritePrice(kind);
13504
+ let debitKey2 = typeof storedBilling?.debitKey === "string" ? storedBilling.debitKey : "";
13505
+ if (!debitKey2) {
13506
+ const fallback = await chargeListingWrite({
13507
+ surface: "local_sourcebook",
13508
+ kind,
13509
+ userId: submission.ownerUserId,
13510
+ idempotencyKey: `worker:${submission.id}:revision:${submission.draftRevision}`
13511
+ });
13512
+ if ("insufficient" in fallback) throw new Error(fallback.insufficient.message);
13513
+ debitKey2 = fallback.debitKey;
13514
+ }
13515
+ const maxPages = LOCAL_SOURCEBOOK_MAX_PAGES;
13329
13516
  let site = null;
13330
13517
  let maps = null;
13331
13518
  let siteError = null;
13332
13519
  let mapsError = null;
13333
- let reviewBillingLimited = false;
13334
- let siteHoldMc = 0;
13335
- let siteActualMc = 0;
13336
- let mapsRefunded = false;
13337
- let reviewsBilledMc = 0;
13338
- if (maxPages < 1) {
13339
- siteError = "The subscriber balance covered exact-place acquisition but not a website page.";
13340
- } else {
13341
- siteHoldMc = maxPages * MC_COSTS.page_scrape;
13342
- const siteDescription = `Local Sourcebook: ${new URL(submission.websiteUrl).hostname}`;
13343
- const siteHold = await debitMcIdempotent(
13344
- submission.ownerUserId,
13345
- siteHoldMc,
13346
- LedgerOperation.EXTRACT_SITE_HOLD,
13347
- siteDescription,
13348
- `${billingCycle}:website-hold`
13349
- );
13350
- if (!siteHold.ok) {
13351
- siteError = "The subscriber balance changed before the website acquisition hold could be placed.";
13352
- siteHoldMc = 0;
13353
- } else {
13354
- try {
13355
- site = await provider.extractWebsite(submission, maxPages);
13356
- const successfulPages2 = site.pages.filter(isPageExtractionSuccessful).length;
13357
- siteActualMc = successfulPages2 * MC_COSTS.page_scrape;
13358
- const refund = siteHoldMc - siteActualMc;
13359
- if (refund > 0) await creditMcIdempotent(submission.ownerUserId, refund, LedgerOperation.EXTRACT_SITE_REFUND, `Local Sourcebook unused website-page hold: ${submission.id}`, `${billingCycle}:website-unused-refund`);
13360
- await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_website", status: "done", query: submission.websiteUrl, resultCount: successfulPages2, result: { submissionId: submission.id, discovered: site.spider.totalFound, captured: successfulPages2, truncated: site.truncated } });
13361
- } catch (error) {
13362
- siteError = errorMessage2(error);
13363
- await creditMcIdempotent(submission.ownerUserId, siteHoldMc, LedgerOperation.EXTRACT_SITE_REFUND, `Local Sourcebook failed website acquisition: ${submission.id}`, `${billingCycle}:website-failure-refund`);
13364
- siteHoldMc = 0;
13365
- await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_website", status: "failed", query: submission.websiteUrl, error: siteError });
13366
- }
13367
- }
13368
- }
13369
13520
  try {
13370
- maps = await provider.extractMaps(submission, LOCAL_SOURCEBOOK_MAX_REVIEWS);
13371
- validateLocalSourcebookMapsIdentity(submission, maps);
13372
- const completeReviewCount = maps.reviews.filter((review) => Boolean(review.text?.trim())).length;
13373
- if (completeReviewCount > 0) {
13374
- const reviewCost = completeReviewCount * MC_COSTS.maps_review;
13375
- const debit = await debitMcIdempotent(
13376
- submission.ownerUserId,
13377
- reviewCost,
13378
- LedgerOperation.MAPS_REVIEW,
13379
- `Local Sourcebook reviews: ${submission.businessName}`,
13380
- `${billingCycle}:maps-reviews`
13381
- );
13382
- if (debit.ok) {
13383
- reviewsBilledMc = reviewCost;
13384
- } else {
13385
- reviewBillingLimited = true;
13386
- maps.reviews = [];
13387
- }
13521
+ try {
13522
+ site = await provider.extractWebsite(submission, maxPages);
13523
+ const successfulPages2 = site.pages.filter(isPageExtractionSuccessful).length;
13524
+ await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_website", status: "done", query: submission.websiteUrl, resultCount: successfulPages2, result: { submissionId: submission.id, discovered: site.spider.totalFound, captured: successfulPages2, truncated: site.truncated } });
13525
+ } catch (error) {
13526
+ siteError = errorMessage2(error);
13527
+ await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_website", status: "failed", query: submission.websiteUrl, error: siteError });
13388
13528
  }
13389
- await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_maps", status: "done", query: submission.businessName, location: submission.state.toUpperCase(), resultCount: maps.reviews.length, result: { submissionId: submission.id, placeUrl: maps.placeUrl, cid: maps.cidDecimal ?? maps.cid, reviewsStatus: maps.reviewsStatus, reviewsRetained: maps.reviews.length, services: maps.services.length, areasServed: maps.areasServed.length } });
13529
+ try {
13530
+ maps = await provider.extractMaps(submission, LOCAL_SOURCEBOOK_MAX_REVIEWS);
13531
+ validateLocalSourcebookMapsIdentity(submission, maps);
13532
+ await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_maps", status: "done", query: submission.businessName, location: submission.state.toUpperCase(), resultCount: maps.reviews.length, result: { submissionId: submission.id, placeUrl: maps.placeUrl, cid: maps.cidDecimal ?? maps.cid, reviewsStatus: maps.reviewsStatus, reviewsRetained: maps.reviews.length, services: maps.services.length, areasServed: maps.areasServed.length } });
13533
+ } catch (error) {
13534
+ mapsError = errorMessage2(error);
13535
+ maps = null;
13536
+ await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_maps", status: "failed", query: submission.businessName, location: submission.state.toUpperCase(), error: mapsError });
13537
+ }
13538
+ if (!maps) throw new Error(`Exact-place acquisition is required before publication. Maps: ${mapsError || "not available"}. Website: ${siteError || (site ? "captured" : "not available")}.`);
13539
+ const compiled = compileLocalSourcebookListing({
13540
+ submission,
13541
+ site,
13542
+ maps,
13543
+ requestedMaxPages: maxPages,
13544
+ requestedMaxReviews: LOCAL_SOURCEBOOK_MAX_REVIEWS,
13545
+ siteError,
13546
+ mapsError,
13547
+ reviewBillingLimited: false
13548
+ });
13549
+ const coverage = {
13550
+ ...compiled.coverage,
13551
+ billing: {
13552
+ mode: "fixed_listing_write",
13553
+ kind,
13554
+ credits: price.credits,
13555
+ amountMc: price.mc,
13556
+ debitKey: debitKey2,
13557
+ acquisitionIncluded: true
13558
+ }
13559
+ };
13560
+ const completed = await completeLocalSourcebookAcquisition(submission.id, compiled.listing, coverage, submission.draftRevision);
13561
+ if (!completed) throw new Error("The Local Sourcebook draft could not be saved after acquisition.");
13562
+ return completed;
13390
13563
  } catch (error) {
13391
- mapsError = errorMessage2(error);
13392
- maps = null;
13393
- await creditMcIdempotent(submission.ownerUserId, MC_COSTS.maps_place, LedgerOperation.REFUND, `Local Sourcebook failed exact-place acquisition: ${submission.id}`, `${billingCycle}:maps-failure-refund`);
13394
- mapsRefunded = true;
13395
- await logRequestEvent({ userId: submission.ownerUserId, source: "local_sourcebook_maps", status: "failed", query: submission.businessName, location: submission.state.toUpperCase(), error: mapsError });
13396
- }
13397
- if (!maps) throw new Error(`Exact-place acquisition is required before publication. Maps: ${mapsError || "not available"}. Website: ${siteError || (site ? "captured" : "not available")}.`);
13398
- const compiled = compileLocalSourcebookListing({
13399
- submission,
13400
- site,
13401
- maps,
13402
- requestedMaxPages: maxPages,
13403
- requestedMaxReviews: LOCAL_SOURCEBOOK_MAX_REVIEWS,
13404
- siteError,
13405
- mapsError,
13406
- reviewBillingLimited
13407
- });
13408
- const coverage = {
13409
- ...compiled.coverage,
13410
- billing: {
13411
- websiteHeldMc: siteHoldMc,
13412
- websiteUsedMc: siteActualMc,
13413
- mapsUsedMc: mapsRefunded ? 0 : MC_COSTS.maps_place,
13414
- reviewsUsedMc: reviewsBilledMc,
13415
- reviewBillingLimited
13564
+ const current = await getLocalSourcebookSubmission(submission.id).catch(() => null);
13565
+ const revisionAlreadyPublished = current?.status === "published" && current.publishedRevision != null && current.draftRevision > submission.draftRevision;
13566
+ if (!revisionAlreadyPublished) {
13567
+ await refundPersistedListingWrite({
13568
+ surface: "local_sourcebook",
13569
+ kind,
13570
+ userId: submission.ownerUserId,
13571
+ debitKey: debitKey2,
13572
+ reason: `Local Sourcebook acquisition failed: ${errorMessage2(error)}`
13573
+ }).catch(() => null);
13416
13574
  }
13417
- };
13418
- const completed = await completeLocalSourcebookAcquisition(submission.id, compiled.listing, coverage, submission.draftRevision);
13419
- if (!completed) throw new Error("The Local Sourcebook draft could not be saved after acquisition.");
13420
- return completed;
13575
+ throw error;
13576
+ }
13421
13577
  }
13422
13578
  async function processLocalSourcebookSubmission(submissionId) {
13423
13579
  const submission = await claimLocalSourcebookAcquisition(submissionId);
@@ -21313,6 +21469,7 @@ var DirectoryIdentitySchema = z25.object({
21313
21469
  state: z25.string().trim().length(2).transform((value) => value.toLowerCase()),
21314
21470
  businessName: z25.string().trim().min(2).max(160),
21315
21471
  websiteUrl: z25.url().refine((value) => /^https?:\/\//.test(value), "websiteUrl must use http or https"),
21472
+ schemaOrgType: LocalSourcebookSchemaTypeSchema.optional(),
21316
21473
  slug: z25.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(100).optional(),
21317
21474
  tags: z25.array(z25.string().trim().min(1).max(60)).max(20).default([]),
21318
21475
  idempotencyKey: z25.string().trim().min(8).max(200)
@@ -21397,7 +21554,7 @@ async function resolveLocalSourcebookTags(candidates) {
21397
21554
  function getLocalSourcebookContract(category) {
21398
21555
  return {
21399
21556
  name: "Local Sourcebook listing",
21400
- version: "local_sourcebook_listing_v2",
21557
+ version: "local_sourcebook_listing_v3",
21401
21558
  purpose: "An owner-scoped, evidence-backed local business record with a separate public profile and complete captured-review archive.",
21402
21559
  categories: LOCAL_SOURCEBOOK_CATEGORIES,
21403
21560
  selectedCategory: category ?? null,
@@ -21408,11 +21565,14 @@ function getLocalSourcebookContract(category) {
21408
21565
  reviews: "https://{category}.localsourcebook.com/{state}/{business-slug}/reviews"
21409
21566
  },
21410
21567
  requiredCreateFields: ["category", "state", "businessName", "websiteUrl", "tags", "idempotencyKey"],
21411
- systemCompiledSections: ["identity", "services/products", "service areas/locations", "genuine media", "reviews and theme counts", "evidence", "answered FAQ", "collection receipts"],
21568
+ optionalCreateFields: ["schemaOrgType"],
21569
+ systemCompiledSections: ["identity", "LocalBusiness JSON-LD", "services/products", "service areas/locations", "genuine media", "reviews and theme counts", "evidence", "answered FAQ", "collection receipts"],
21412
21570
  workflow: ["list-local-sourcebook-tags", "get-local-sourcebook-contract", "prepare-local-sourcebook-write", "validate-local-sourcebook-write", "local-sourcebook-capture", "local_sourcebook_submission_status"],
21413
21571
  ownership: "Only the MCP Scraper account that captures a listing can read it or request a paid evidence refresh.",
21572
+ billing: { validationCredits: 0, createCredits: 10, editCredits: 2, acquisitionIncluded: true, chargeEvent: "accepted_write" },
21414
21573
  acquisition: { websitePagesMaximum: 100, reviewsMaximum: 500, exactMapsIdentityRequired: true, partialResultsRequireStoppingReasons: true },
21415
21574
  publication: { automatic: true, evidenceCompiledOnly: true, ownerAuthoredClaimsAccepted: false, adminReviewRequired: false, lastPublishedRevisionRemainsPublicDuringRefresh: true },
21575
+ structuredData: localSourcebookSchemaContract(category),
21416
21576
  tagPolicy: { inspectVocabularyFirst: true, minimum: 1, maximum: 20, newTagsRequire: ["central=true", "reusable=true", "description"], newTagsStartAs: "pending" }
21417
21577
  };
21418
21578
  }
@@ -21427,17 +21587,20 @@ async function prepareLocalSourcebookWrite(input) {
21427
21587
  ];
21428
21588
  const tagResolutions = await resolveLocalSourcebookTags(candidates);
21429
21589
  const publicUrls = localSourcebookPublicUrls(parsed.category, parsed.state, slug2);
21590
+ const schemaOrgType = parsed.schemaOrgType ?? defaultLocalSourcebookSchemaType(parsed.category);
21430
21591
  return {
21431
21592
  ok: true,
21432
21593
  route: { category: parsed.category, state: parsed.state, slug: slug2, ...publicUrls },
21433
- proposedWrite: { ...parsed, slug: slug2, tags: tagResolutions.filter((item) => item.action === "reuse").map((item) => item.tag) },
21594
+ proposedWrite: { ...parsed, schemaOrgType, slug: slug2, tags: tagResolutions.filter((item) => item.action === "reuse").map((item) => item.tag) },
21434
21595
  contract: getLocalSourcebookContract(parsed.category),
21435
21596
  tagResolutions,
21436
21597
  instructions: [
21437
21598
  "Resolve every review candidate before capture; do not silently create a near-duplicate tag.",
21438
21599
  "Call validate-local-sourcebook-write with the proposed identity, resolved tags, and any required tag decisions.",
21600
+ "Keep the proposed schemaOrgType or choose a more specific accepted LocalBusiness subtype from the contract. Arbitrary JSON-LD is not accepted.",
21439
21601
  "Call local-sourcebook-capture only after validation returns valid=true. Capture queues paid acquisition and automatically publishes the resulting system-compiled evidence revision.",
21440
- "Owners cannot supply public listing claims directly. Request local_sourcebook_refresh when source-backed public facts need to be reacquired."
21602
+ "Creating the listing costs 10 Credits total, including acquisition. Validation is free.",
21603
+ "Owners cannot supply public listing claims directly. A source-backed local_sourcebook_refresh costs 2 Credits."
21441
21604
  ]
21442
21605
  };
21443
21606
  }
@@ -21447,7 +21610,10 @@ async function validateLocalSourcebookWrite(input) {
21447
21610
  let normalizedIdentity2;
21448
21611
  const parsedIdentity = DirectoryIdentitySchema.safeParse(input.identity);
21449
21612
  if (!parsedIdentity.success) errors.push(...parsedIdentity.error.issues.map((issue) => `identity.${issue.path.join(".")}: ${issue.message}`));
21450
- else normalizedIdentity2 = parsedIdentity.data;
21613
+ else normalizedIdentity2 = {
21614
+ ...parsedIdentity.data,
21615
+ schemaOrgType: parsedIdentity.data.schemaOrgType ?? defaultLocalSourcebookSchemaType(parsedIdentity.data.category)
21616
+ };
21451
21617
  const candidates = input.tagCandidates ?? (normalizedIdentity2?.tags.map((tag) => ({ tag })) ?? []);
21452
21618
  const resolutions = await resolveLocalSourcebookTags(candidates);
21453
21619
  const decisions = new Map((input.tagDecisions ?? []).map((decision) => [normalizeTag(decision.tag), decision]));
@@ -21496,6 +21662,7 @@ var SubmitSchema = z26.object({
21496
21662
  state: z26.string().trim().length(2).transform((value) => value.toLowerCase()),
21497
21663
  businessName: z26.string().trim().min(2).max(160),
21498
21664
  websiteUrl: z26.url().refine((value) => /^https?:\/\//.test(value), "websiteUrl must use http or https"),
21665
+ schemaOrgType: LocalSourcebookSchemaTypeSchema.optional(),
21499
21666
  slug: z26.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(100).optional(),
21500
21667
  idempotencyKey: z26.string().trim().min(8).max(200).optional()
21501
21668
  }).strict();
@@ -21506,6 +21673,18 @@ var ValidateWriteSchema = z26.object({
21506
21673
  tagDecisions: z26.array(DirectoryTagDecisionSchema).max(20).optional()
21507
21674
  }).strict();
21508
21675
  var CaptureWriteSchema = ValidateWriteSchema.extend({ idempotencyKey: z26.string().trim().min(8).max(200).optional() });
21676
+ function withCanonicalLocalSourcebookUrls(listing) {
21677
+ const category = z26.enum(LOCAL_SOURCEBOOK_CATEGORIES).safeParse(listing.category);
21678
+ const state = typeof listing.state === "string" ? listing.state : "";
21679
+ const listingSlug = typeof listing.slug === "string" ? listing.slug : "";
21680
+ if (!category.success || !state || !listingSlug) return listing;
21681
+ const publicUrls = localSourcebookPublicUrls(category.data, state, listingSlug);
21682
+ return {
21683
+ ...listing,
21684
+ canonicalPaths: { profile: publicUrls.profilePath, reviews: publicUrls.reviewsPath },
21685
+ canonicalUrls: { profile: publicUrls.profileUrl, reviews: publicUrls.reviewsUrl }
21686
+ };
21687
+ }
21509
21688
  function acquisitionPlan(websiteUrl) {
21510
21689
  return {
21511
21690
  website: { startUrl: websiteUrl, mode: "broad_site_crawl", capture: ["homepage", "services", "products", "locations", "service_areas", "about", "team_staff", "contact", "policies"] },
@@ -21540,13 +21719,19 @@ localSourcebookApp.post("/capture", createApiKeyAuth(), async (c) => {
21540
21719
  if (!parsed.success) return c.json({ error: "invalid_capture_request", issues: parsed.error.issues }, 400);
21541
21720
  const validation = await validateLocalSourcebookWrite(parsed.data);
21542
21721
  if (!validation.valid) return c.json({ error: "invalid_directory_write", ...validation }, 400);
21722
+ let billing = null;
21723
+ let persisted = false;
21543
21724
  try {
21544
21725
  const identity = validation.normalizedIdentity;
21545
- if (!parsed.data.idempotencyKey && !identity.idempotencyKey) {
21726
+ const idempotencyKey3 = parsed.data.idempotencyKey ?? identity.idempotencyKey;
21727
+ if (!idempotencyKey3) {
21546
21728
  return c.json({ error: "idempotency_key_required", message: "A stable idempotencyKey is required for a new Local Sourcebook capture." }, 400);
21547
21729
  }
21548
21730
  const listingSlug = identity.slug ?? slug(identity.businessName);
21549
21731
  const publicUrls = localSourcebookPublicUrls(identity.category, identity.state, listingSlug);
21732
+ const charged = await chargeListingWrite({ surface: "local_sourcebook", kind: "create", userId: Number(user.id), idempotencyKey: idempotencyKey3 });
21733
+ if ("insufficient" in charged) return c.json(charged.insufficient, 402);
21734
+ billing = charged;
21550
21735
  const submission = await createLocalSourcebookSubmission({
21551
21736
  ownerUserId: Number(user.id),
21552
21737
  category: identity.category,
@@ -21554,7 +21739,9 @@ localSourcebookApp.post("/capture", createApiKeyAuth(), async (c) => {
21554
21739
  slug: listingSlug,
21555
21740
  businessName: identity.businessName,
21556
21741
  websiteUrl: identity.websiteUrl,
21557
- idempotencyKey: parsed.data.idempotencyKey ?? identity.idempotencyKey,
21742
+ schemaOrgType: identity.schemaOrgType,
21743
+ idempotencyKey: idempotencyKey3,
21744
+ billing: { kind: billing.kind, credits: billing.credits, amountMc: billing.amountMc, debitKey: billing.debitKey },
21558
21745
  payload: {
21559
21746
  id: "",
21560
21747
  category: identity.category,
@@ -21562,6 +21749,7 @@ localSourcebookApp.post("/capture", createApiKeyAuth(), async (c) => {
21562
21749
  slug: listingSlug,
21563
21750
  name: identity.businessName,
21564
21751
  websiteUrl: identity.websiteUrl,
21752
+ schemaOrgType: identity.schemaOrgType,
21565
21753
  tags: validation.normalizedTags,
21566
21754
  canonicalPaths: { profile: publicUrls.profilePath, reviews: publicUrls.reviewsPath },
21567
21755
  canonicalUrls: { profile: publicUrls.profileUrl, reviews: publicUrls.reviewsUrl },
@@ -21572,10 +21760,12 @@ localSourcebookApp.post("/capture", createApiKeyAuth(), async (c) => {
21572
21760
  reviews: []
21573
21761
  }
21574
21762
  });
21763
+ persisted = true;
21575
21764
  await persistLocalSourcebookTags(submission.id, Number(user.id), validation.normalizedTags, parsed.data.tagDecisions);
21576
21765
  const dispatched = await dispatchLocalSourcebookAcquisition(submission.id).catch(() => false);
21577
- return c.json({ ok: true, valid: true, submission, publicUrls: localSourcebookPublicationLinks(submission), normalizedTags: validation.normalizedTags, acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(identity.websiteUrl) }, 202);
21766
+ return c.json({ ok: true, valid: true, submission, publicUrls: localSourcebookPublicationLinks(submission), normalizedTags: validation.normalizedTags, acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(identity.websiteUrl), billing: publicListingWriteBilling(billing) }, 202);
21578
21767
  } catch (error) {
21768
+ if (billing && !persisted) await refundListingWrite(billing, "Local Sourcebook creation failed before persistence.").catch(() => null);
21579
21769
  const message = error instanceof Error ? error.message : String(error);
21580
21770
  const conflict = message.includes("revision conflict") ? "revision_conflict" : message.includes("idempotency key conflicts") ? "idempotency_conflict" : null;
21581
21771
  return c.json({ error: conflict ?? "capture_failed", message }, conflict ? 409 : 400);
@@ -21587,13 +21777,22 @@ localSourcebookApp.post("/submissions", createApiKeyAuth(), async (c) => {
21587
21777
  const parsed = SubmitSchema.safeParse(await c.req.json().catch(() => null));
21588
21778
  if (!parsed.success) return c.json({ error: "invalid_submission", issues: parsed.error.issues }, 400);
21589
21779
  const input = parsed.data;
21780
+ if (!input.idempotencyKey) return c.json({ error: "idempotency_key_required", message: "A stable idempotencyKey is required for a new Local Sourcebook listing." }, 400);
21590
21781
  const listingSlug = input.slug ?? slug(input.businessName);
21782
+ const schemaOrgType = input.schemaOrgType ?? defaultLocalSourcebookSchemaType(input.category);
21591
21783
  const publicUrls = localSourcebookPublicUrls(input.category, input.state, listingSlug);
21784
+ let billing = null;
21785
+ let persisted = false;
21592
21786
  try {
21787
+ const charged = await chargeListingWrite({ surface: "local_sourcebook", kind: "create", userId: Number(user.id), idempotencyKey: input.idempotencyKey });
21788
+ if ("insufficient" in charged) return c.json(charged.insufficient, 402);
21789
+ billing = charged;
21593
21790
  const submission = await createLocalSourcebookSubmission({
21594
21791
  ownerUserId: Number(user.id),
21595
21792
  ...input,
21793
+ schemaOrgType,
21596
21794
  slug: listingSlug,
21795
+ billing: { kind: billing.kind, credits: billing.credits, amountMc: billing.amountMc, debitKey: billing.debitKey },
21597
21796
  payload: {
21598
21797
  id: "",
21599
21798
  category: input.category,
@@ -21601,6 +21800,7 @@ localSourcebookApp.post("/submissions", createApiKeyAuth(), async (c) => {
21601
21800
  slug: listingSlug,
21602
21801
  name: input.businessName,
21603
21802
  websiteUrl: input.websiteUrl,
21803
+ schemaOrgType,
21604
21804
  canonicalPaths: { profile: publicUrls.profilePath, reviews: publicUrls.reviewsPath },
21605
21805
  canonicalUrls: { profile: publicUrls.profileUrl, reviews: publicUrls.reviewsUrl },
21606
21806
  verificationState: "submitted",
@@ -21610,9 +21810,11 @@ localSourcebookApp.post("/submissions", createApiKeyAuth(), async (c) => {
21610
21810
  reviews: []
21611
21811
  }
21612
21812
  });
21813
+ persisted = true;
21613
21814
  const dispatched = await dispatchLocalSourcebookAcquisition(submission.id).catch(() => false);
21614
- return c.json({ ok: true, submission, publicUrls: localSourcebookPublicationLinks(submission), acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(input.websiteUrl) }, 202);
21815
+ return c.json({ ok: true, submission, publicUrls: localSourcebookPublicationLinks(submission), acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(input.websiteUrl), billing: publicListingWriteBilling(billing) }, 202);
21615
21816
  } catch (error) {
21817
+ if (billing && !persisted) await refundListingWrite(billing, "Local Sourcebook creation failed before persistence.").catch(() => null);
21616
21818
  return c.json({ error: "submission_conflict", message: error instanceof Error ? error.message : String(error) }, 409);
21617
21819
  }
21618
21820
  });
@@ -21629,22 +21831,45 @@ localSourcebookApp.on(["PATCH", "POST"], "/submissions/:id/draft", createApiKeyA
21629
21831
  localSourcebookApp.post("/submissions/:id/refresh", createApiKeyAuth(), async (c) => {
21630
21832
  const user = c.get("user");
21631
21833
  if (!user.subscription_tier) return c.json({ error: "subscription_required" }, 402);
21834
+ const userId = Number(user.id);
21835
+ const existing = await getLocalSourcebookSubmission(c.req.param("id"), userId);
21836
+ if (!existing) return c.json({ error: "not_found" }, 404);
21837
+ if (existing.status === "rejected" || existing.status === "unpublished") {
21838
+ return c.json({ error: "moderated_listing", message: `A ${existing.status} Local Sourcebook listing cannot be refreshed by its owner.` }, 409);
21839
+ }
21840
+ if (existing.status === "queued" || existing.status === "enriching") {
21841
+ return c.json({ error: "acquisition_in_progress", message: "This listing already has an acquisition in progress." }, 409);
21842
+ }
21843
+ const idempotencyKey3 = `refresh:${existing.id}:revision:${existing.draftRevision}`;
21844
+ let billing = null;
21632
21845
  try {
21633
- const submission = await queueLocalSourcebookRefresh(c.req.param("id"), Number(user.id));
21634
- if (!submission) return c.json({ error: "not_found" }, 404);
21846
+ const charged = await chargeListingWrite({ surface: "local_sourcebook", kind: "edit", userId, idempotencyKey: idempotencyKey3 });
21847
+ if ("insufficient" in charged) return c.json(charged.insufficient, 402);
21848
+ billing = charged;
21849
+ const submission = await queueLocalSourcebookRefresh(c.req.param("id"), userId, {
21850
+ kind: billing.kind,
21851
+ credits: billing.credits,
21852
+ amountMc: billing.amountMc,
21853
+ debitKey: billing.debitKey
21854
+ });
21855
+ if (!submission) {
21856
+ await refundListingWrite(billing, "Local Sourcebook listing was not found before refresh persistence.").catch(() => null);
21857
+ return c.json({ error: "not_found" }, 404);
21858
+ }
21635
21859
  const dispatched = await dispatchLocalSourcebookAcquisition(submission.id).catch(() => false);
21636
- return c.json({ ok: true, submission, publicUrls: localSourcebookPublicationLinks(submission), acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(submission.websiteUrl) }, 202);
21860
+ return c.json({ ok: true, submission, publicUrls: localSourcebookPublicationLinks(submission), acquisition: { queued: true, dispatched }, acquisitionPlan: acquisitionPlan(submission.websiteUrl), billing: publicListingWriteBilling(billing) }, 202);
21637
21861
  } catch (error) {
21862
+ if (billing) await refundListingWrite(billing, "Local Sourcebook refresh failed before persistence.").catch(() => null);
21638
21863
  return c.json({ error: "moderated_listing", message: error instanceof Error ? error.message : String(error) }, 409);
21639
21864
  }
21640
21865
  });
21641
21866
  var publicLocalSourcebookApp = new Hono17();
21642
- publicLocalSourcebookApp.get("/", async (c) => c.json({ ok: true, listings: await listAllPublicLocalSourcebook() }));
21643
- publicLocalSourcebookApp.get("/:category", async (c) => c.json({ ok: true, listings: await listAllPublicLocalSourcebook(c.req.param("category")) }));
21644
- publicLocalSourcebookApp.get("/:category/:state", async (c) => c.json({ ok: true, listings: await listPublicLocalSourcebook(c.req.param("category"), c.req.param("state").toLowerCase()) }));
21867
+ publicLocalSourcebookApp.get("/", async (c) => c.json({ ok: true, listings: (await listAllPublicLocalSourcebook()).map(withCanonicalLocalSourcebookUrls) }));
21868
+ publicLocalSourcebookApp.get("/:category", async (c) => c.json({ ok: true, listings: (await listAllPublicLocalSourcebook(c.req.param("category"))).map(withCanonicalLocalSourcebookUrls) }));
21869
+ publicLocalSourcebookApp.get("/:category/:state", async (c) => c.json({ ok: true, listings: (await listPublicLocalSourcebook(c.req.param("category"), c.req.param("state").toLowerCase())).map(withCanonicalLocalSourcebookUrls) }));
21645
21870
  publicLocalSourcebookApp.get("/:category/:state/:slug", async (c) => {
21646
21871
  const listing = await getPublicLocalSourcebook(c.req.param("category"), c.req.param("state").toLowerCase(), c.req.param("slug"));
21647
- return listing ? c.json({ ok: true, listing }) : c.json({ error: "not_found" }, 404);
21872
+ return listing ? c.json({ ok: true, listing: withCanonicalLocalSourcebookUrls(listing) }) : c.json({ error: "not_found" }, 404);
21648
21873
  });
21649
21874
  var adminLocalSourcebookApp = new Hono17();
21650
21875
  adminLocalSourcebookApp.use("*", adminAuth);
@@ -31006,6 +31231,12 @@ async function prepareCommonsEntity(input, user) {
31006
31231
  writePath: "commons_prepare_entity -> commons_validate_entity -> commons_submit_entity"
31007
31232
  },
31008
31233
  contract,
31234
+ billing: {
31235
+ validationCredits: LISTING_WRITE_RATE_POLICY.validationCredits,
31236
+ createCredits: LISTING_WRITE_RATE_POLICY.create.credits,
31237
+ editCredits: LISTING_WRITE_RATE_POLICY.edit.credits,
31238
+ chargeEvent: LISTING_WRITE_RATE_POLICY.chargeEvent
31239
+ },
31009
31240
  duplicateCandidates,
31010
31241
  tagResolutions,
31011
31242
  headingPlan: {
@@ -31020,7 +31251,8 @@ async function prepareCommonsEntity(input, user) {
31020
31251
  "Use the returned profile as a contract, not a rigid empty template: do not include History, Reception, Pricing, Timeline, or similar sections unless source evidence supports them.",
31021
31252
  "Write neutral encyclopedia prose. Do not paste raw scrape Markdown, marketing layout fragments, or empty/filler headings.",
31022
31253
  "Attach a featured image for auto-publish eligibility, preserve original URL/source byline/relCanonical when republishing, and put media assets in the media array.",
31023
- "Call commons_validate_entity with the composed payload before commons_submit_entity."
31254
+ "Call commons_validate_entity with the composed payload before commons_submit_entity.",
31255
+ "Validation is free. Submitting a new entity costs 10 Credits; submitting an edit to an existing entity costs 2 Credits."
31024
31256
  ]
31025
31257
  };
31026
31258
  }
@@ -31109,6 +31341,14 @@ async function getCommonsEntity(idOrSlug) {
31109
31341
  });
31110
31342
  return result.rows[0] ? rowToEntity(result.rows[0]) : null;
31111
31343
  }
31344
+ async function classifyCommonsWrite(input, user) {
31345
+ await ensureCommonsSchema();
31346
+ const normalized = normalizeSubmitInput(input);
31347
+ const existingProposal = await findProposalByIdempotency(user.id, normalized.idempotencyKey);
31348
+ if (existingProposal) return existingProposal.proposalType === "edit_entity" ? "edit" : "create";
31349
+ const existing = await findEntityBySlugOrIdAnyStatus(normalized.slug, normalized.entityId);
31350
+ return existing ? "edit" : "create";
31351
+ }
31112
31352
  async function submitCommonsEntity(input, user) {
31113
31353
  await ensureCommonsSchema();
31114
31354
  const normalized = normalizeSubmitInput(input);
@@ -31373,7 +31613,8 @@ function commonsDatabaseReport() {
31373
31613
  normalWorkflow: ["commons_prepare_entity", "commons_validate_entity", "commons_submit_entity"],
31374
31614
  writesCreate: ["commons_proposals", "commons_ledger", "commons_entities when accepted"],
31375
31615
  safeAutoPublish: "New non-conflicting entities with title, description, featured image, and body/source evidence.",
31376
- conflictBehavior: "Existing entity edits require baseRevision. Otherwise the proposal is pending_review."
31616
+ conflictBehavior: "Existing entity edits require baseRevision. Otherwise the proposal is pending_review.",
31617
+ billing: { validationCredits: 0, createCredits: LISTING_WRITE_RATE_POLICY.create.credits, editCredits: LISTING_WRITE_RATE_POLICY.edit.credits }
31377
31618
  },
31378
31619
  searchCorpus: ["title", "disambiguationName", "description", "tags", "keywords", "relatedEntities", "bodyMarkdown", "sections", "media", "source metadata", "jsonLd"],
31379
31620
  indexing: commonsIndexingPolicy(),
@@ -32664,10 +32905,22 @@ commonsApp.post("/entities/validate", auth2, async (c) => {
32664
32905
  commonsApp.post("/entities/propose", auth2, async (c) => {
32665
32906
  const parsed = SubmitEntitySchema.safeParse(await c.req.json().catch(() => ({})));
32666
32907
  if (!parsed.success) return validationError(c, parsed.error);
32908
+ let billing = null;
32667
32909
  try {
32910
+ const user = c.get("user");
32911
+ const kind = await classifyCommonsWrite(parsed.data, user);
32912
+ const charged = await chargeListingWrite({
32913
+ surface: "transparent_commons",
32914
+ kind,
32915
+ userId: Number(user.id),
32916
+ idempotencyKey: parsed.data.idempotencyKey
32917
+ });
32918
+ if ("insufficient" in charged) return c.json(charged.insufficient, 402);
32919
+ billing = charged;
32668
32920
  const result = await submitCommonsEntity(parsed.data, c.get("user"));
32669
- return c.json({ ok: true, data: result }, result.applied ? 201 : 202);
32921
+ return c.json({ ok: true, data: result, billing: publicListingWriteBilling(billing) }, result.applied ? 201 : 202);
32670
32922
  } catch (error) {
32923
+ if (billing) await refundListingWrite(billing, "Transparent Commons write failed before persistence.").catch(() => null);
32671
32924
  if (error instanceof CommonsRepositoryError) {
32672
32925
  return c.json({ ok: false, error: error.code, message: error.message }, repositoryStatus(error));
32673
32926
  }
@@ -40751,7 +41004,7 @@ app.get("/cron/tick", async (c) => {
40751
41004
  if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
40752
41005
  return c.json({ error: "Unauthorized" }, 401);
40753
41006
  }
40754
- const { drainQueue } = await import("./worker-73AYU4IK.js");
41007
+ const { drainQueue } = await import("./worker-4LZGRHDA.js");
40755
41008
  const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
40756
41009
  const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
40757
41010
  const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup, connectedAccountBilling, directoryWorkflowDispatch, directoryWorkflowReconciliation, directoryArtifactCleanup, siteExtractArtifactCleanup, siteExtractRedispatch, siteExtractReconciliation, paaHarvestRedispatch, paaHarvestReconciliation, localSourcebookRedispatch] = await Promise.all([
@@ -40806,8 +41059,8 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
40806
41059
  finishExtractJob: finishExtractJob2,
40807
41060
  terminalExtractJobStatus: terminalExtractJobStatus2,
40808
41061
  extractJobLimitInfo: extractJobLimitInfo2
40809
- } = await import("./site-extract-repository-CKCJDP7N.js");
40810
- const { assembleExtractArtifacts } = await import("./extract-bundle-U3MNYDKC.js");
41062
+ } = await import("./site-extract-repository-G723PZIL.js");
41063
+ const { assembleExtractArtifacts } = await import("./extract-bundle-DYMQRQWG.js");
40811
41064
  const existing = await getExtractJob2(jobId2);
40812
41065
  if (!existing) return c.json({ error: "job not found" }, 404);
40813
41066
  if (existing.status !== "failed") {
@@ -40834,7 +41087,7 @@ app.post("/api/internal/extract-refinalize/:id", async (c) => {
40834
41087
  if (!updated) return c.json({ error: "job state changed during re-finalization" }, 409);
40835
41088
  let settlement = "already_settled_or_refunded";
40836
41089
  if (job.billedMc == null && job.userId != null) {
40837
- const { settleExtractJob: settleExtractJob3, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-CKCJDP7N.js");
41090
+ const { settleExtractJob: settleExtractJob3, countSuccessfulPages: countSuccessfulPages2 } = await import("./site-extract-repository-G723PZIL.js");
40838
41091
  const heldMc = Number(job.options.heldMc ?? 0);
40839
41092
  const successful = await countSuccessfulPages2(jobId2);
40840
41093
  const usedMc = Math.min(successful * MC_COSTS.page_scrape, heldMc);
@@ -41018,4 +41271,4 @@ app.get("/blog/:slug/", (c) => {
41018
41271
  export {
41019
41272
  app
41020
41273
  };
41021
- //# sourceMappingURL=server-JJLFY46K.js.map
41274
+ //# sourceMappingURL=server-7B75SDNG.js.map