sync-jd-listing-reviews.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import 'dotenv/config';
  2. import { createHash } from 'node:crypto';
  3. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  4. import { dirname, resolve } from 'node:path';
  5. import { z } from 'zod';
  6. import { loadConfig } from '../src/config/env.js';
  7. import { ParseRestClient, parseDate, type ParseObject } from '../src/db/parse-rest.client.js';
  8. import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  9. import { makeReviewKey } from '../src/modules/domestic-voc/domain/identity.js';
  10. import type { ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  11. type JsonRecord = Record<string, unknown>;
  12. interface StoredSource extends ParseObject { productId?: string; payload: ListingSourceSnapshot }
  13. interface StoredReview extends ParseObject { naturalKey: string; productId: string; sourceReviewId?: string; reviewDate?: unknown }
  14. interface Checkpoint {
  15. workspaceId: string;
  16. cohort: string;
  17. nextPage: number;
  18. totalItems: number;
  19. totalPages: number;
  20. pagesProcessed: number;
  21. commentsScanned: number;
  22. commentsMatched: number;
  23. commentsCreated: number;
  24. commentsUpdated: number;
  25. commentsDuplicate: number;
  26. commentsUnmatched: number;
  27. matchedProducts: string[];
  28. startedAt: string;
  29. updatedAt: string;
  30. status: 'running' | 'completed';
  31. phase?: 'windowed';
  32. historyStart?: string;
  33. historyEnd?: string;
  34. windowStart?: string;
  35. windowEnd?: string;
  36. windowsProcessed?: number;
  37. }
  38. const ACTIVE_COHORT = 'listing-jd-v3-formal-625';
  39. const METHOD = 'jingdong.pop.PopCommentJsfService.getVenderCommentsForJos';
  40. const PAGE_SIZE = 50;
  41. const DEFAULT_HISTORY_START = '2026-06-01 00:00:00';
  42. const DEFAULT_HISTORY_END = '2026-08-29 00:00:00';
  43. const args = new Map(process.argv.slice(2).map((arg) => {
  44. const [key, ...rest] = arg.split('=');
  45. return [key!, rest.join('=') || 'true'];
  46. }));
  47. const workspaceId = args.get('--workspace') ?? process.env.SAAS_DEFAULT_WORKSPACE_ID ?? 'demashi';
  48. const scope = args.get('--scope') === 'own' ? 'own' : 'listing-cohort';
  49. const scopeName = scope === 'own' ? 'voc-own-products' : ACTIVE_COHORT;
  50. const checkpointPath = resolve(args.get('--checkpoint') ?? 'logs/jd-listing-review-sync-2026-06-01-to-2026-08-28.json');
  51. const resume = args.get('--resume') === 'true';
  52. const delayMs = Math.max(100, Number(args.get('--delay-ms') ?? 200));
  53. const maxPages = Math.max(1, Number(args.get('--max-pages') ?? Number.POSITIVE_INFINITY));
  54. const historyStartArg = args.get('--history-start') ?? DEFAULT_HISTORY_START;
  55. const historyEndArg = args.get('--history-end') ?? DEFAULT_HISTORY_END;
  56. const windowDays = Math.max(1, Number(args.get('--window-days') ?? 7));
  57. const pruneOutsideRange = args.get('--prune-outside-range') !== 'false';
  58. const sourceSchema = z.object({
  59. JD_SOURCE_PARSE_URL: z.url(),
  60. JD_SOURCE_PARSE_APP_ID: z.string().min(1),
  61. JD_SOURCE_PARSE_MASTER_KEY: z.string().min(1),
  62. JD_APP_KEY: z.string().min(1),
  63. JD_APP_SECRET: z.string().min(1),
  64. });
  65. async function main(): Promise<void> {
  66. const sourceConfig = sourceSchema.parse(process.env);
  67. const config = loadConfig();
  68. const target = new ParseRestClient({
  69. serverUrl: config.parse.serverUrl,
  70. appId: config.parse.appId,
  71. masterKey: config.parse.masterKey,
  72. timeoutMs: config.parse.timeoutMs,
  73. });
  74. const authSource = new ParseRestClient({
  75. serverUrl: sourceConfig.JD_SOURCE_PARSE_URL,
  76. appId: sourceConfig.JD_SOURCE_PARSE_APP_ID,
  77. masterKey: sourceConfig.JD_SOURCE_PARSE_MASTER_KEY,
  78. timeoutMs: config.jdListing.timeoutMs,
  79. });
  80. const authRow = (await authSource.find<JsonRecord>('EcomAuth', {
  81. where: { platform: 'jd', type: 'access_token' }, order: '-createdAt', limit: 1,
  82. })).results[0];
  83. const authData = record(authRow?.['data']);
  84. const accessToken = text(authData['access_token']);
  85. if (!accessToken) throw new Error('jd_authorization_missing');
  86. let allSources: StoredSource[] = [];
  87. let cohortProductIds: Set<string>;
  88. let skuToProduct: Map<string, string>;
  89. if (scope === 'own') {
  90. const ownProducts = await target.findAll<ParseObject & { productId?: string }>(VOC_PARSE_CLASSES.product, {
  91. workspaceId, platform: 'jd', role: 'own',
  92. });
  93. cohortProductIds = new Set(ownProducts.map((row) => text(row.productId)).filter(Boolean));
  94. skuToProduct = buildOwnSkuMap(cohortProductIds);
  95. if (!cohortProductIds.size) throw new Error('listing_review_own_product_scope_empty');
  96. } else {
  97. allSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, { workspaceId, platform: 'jd' });
  98. const currentSources = await target.findAll<StoredSource>(VOC_PARSE_CLASSES.listingSourceSnapshot, {
  99. workspaceId, platform: 'jd', isCurrent: true, catalogIncluded: true, catalogCohort: ACTIVE_COHORT,
  100. });
  101. cohortProductIds = new Set(currentSources.map((row) => row.payload.productId));
  102. if (cohortProductIds.size !== 625) throw new Error(`listing_review_cohort_count_mismatch:${cohortProductIds.size}:625`);
  103. skuToProduct = buildSkuMap(allSources, cohortProductIds);
  104. }
  105. if (!skuToProduct.size) throw new Error('listing_review_sku_map_empty');
  106. const conflicts = scope === 'own' ? [] : findSkuConflicts(allSources, cohortProductIds);
  107. if (conflicts.length) throw new Error(`listing_review_sku_conflicts:${conflicts.slice(0, 5).join(',')}`);
  108. let existingReviews = await target.findAll<StoredReview>(VOC_PARSE_CLASSES.review, { workspaceId, platform: 'jd' });
  109. if (pruneOutsideRange) {
  110. const outsideRange = existingReviews.filter((review) => cohortProductIds.has(review.productId)
  111. && !isWithinJdRange(review.reviewDate, historyStartArg, historyEndArg));
  112. const deleteRequests = outsideRange.map((review) => ({
  113. method: 'DELETE' as const,
  114. path: `/classes/${VOC_PARSE_CLASSES.review}/${review.objectId}`,
  115. }));
  116. for (let index = 0; index < deleteRequests.length; index += 50) {
  117. await writeFmodeBatch(target, deleteRequests.slice(index, index + 50));
  118. }
  119. existingReviews = existingReviews.filter((review) => !outsideRange.includes(review));
  120. console.log(JSON.stringify({ event: 'listing_review_prune', deleted: outsideRange.length, historyStart: historyStartArg, historyEndExclusive: historyEndArg }));
  121. }
  122. const existingByNaturalKey = new Map(existingReviews.map((row) => [row.naturalKey, row]));
  123. const existingBySourceReviewId = new Map(existingReviews
  124. .filter((row) => text(row.sourceReviewId))
  125. .map((row) => [text(row.sourceReviewId), row]));
  126. const seen = new Set(existingByNaturalKey.keys());
  127. let checkpoint = resume ? await readCheckpoint(checkpointPath) : null;
  128. if (checkpoint?.status === 'completed') {
  129. console.log(JSON.stringify({ mode: 'already_completed', checkpointPath, ...checkpoint }, null, 2));
  130. return;
  131. }
  132. const now = new Date().toISOString();
  133. checkpoint ??= {
  134. workspaceId, cohort: scopeName, nextPage: 1, totalItems: 0, totalPages: 0,
  135. pagesProcessed: 0, commentsScanned: 0, commentsMatched: 0, commentsCreated: 0,
  136. commentsUpdated: 0, commentsDuplicate: 0, commentsUnmatched: 0, matchedProducts: [],
  137. startedAt: now, updatedAt: now, status: 'running',
  138. };
  139. if (checkpoint.workspaceId !== workspaceId || checkpoint.cohort !== scopeName) {
  140. throw new Error('listing_review_checkpoint_scope_mismatch');
  141. }
  142. if (checkpoint.phase === 'windowed'
  143. && (checkpoint.historyStart !== normalizeJdDateTime(historyStartArg)
  144. || checkpoint.historyEnd !== normalizeJdDateTime(historyEndArg))) {
  145. throw new Error(`listing_review_checkpoint_range_mismatch:${checkpoint.historyStart}:${checkpoint.historyEnd}`);
  146. }
  147. const matchedProducts = new Set(checkpoint.matchedProducts);
  148. const client = new JdJosReviewClient(sourceConfig.JD_APP_KEY, sourceConfig.JD_APP_SECRET, accessToken);
  149. if (!checkpoint.totalItems) {
  150. const metadata = await client.page(1, PAGE_SIZE);
  151. checkpoint.totalItems = metadata.totalItem;
  152. checkpoint.totalPages = Math.ceil(metadata.totalItem / PAGE_SIZE);
  153. }
  154. if (checkpoint.phase !== 'windowed') {
  155. const oldestStoredReview = existingReviews
  156. .filter((review) => cohortProductIds.has(review.productId))
  157. .map((review) => storedDateIso(review.reviewDate))
  158. .filter((value): value is string => Boolean(value))
  159. .sort()[0];
  160. const historyEnd = normalizeJdDateTime(historyEndArg || oldestStoredReview || jdTomorrow());
  161. const historyStart = normalizeJdDateTime(historyStartArg);
  162. checkpoint.phase = 'windowed';
  163. checkpoint.historyStart = historyStart;
  164. checkpoint.historyEnd = historyEnd;
  165. checkpoint.windowStart = historyStart;
  166. checkpoint.windowEnd = minJdDateTime(addJdDays(historyStart, windowDays), historyEnd);
  167. checkpoint.windowsProcessed = 0;
  168. checkpoint.nextPage = 1;
  169. checkpoint.updatedAt = new Date().toISOString();
  170. await persistCheckpoint(checkpointPath, checkpoint);
  171. console.log(JSON.stringify({ event: 'listing_review_window_migration', historyStart, historyEnd, retainedRecentPages: checkpoint.pagesProcessed }));
  172. }
  173. let pagesThisRun = 0;
  174. while (checkpoint.windowStart && checkpoint.windowEnd && checkpoint.historyEnd
  175. && jdEpoch(checkpoint.windowStart) < jdEpoch(checkpoint.historyEnd)
  176. && pagesThisRun < maxPages) {
  177. const page = checkpoint.nextPage;
  178. const response = await client.page(page, PAGE_SIZE, {
  179. beginTime: checkpoint.windowStart,
  180. endTime: checkpoint.windowEnd,
  181. });
  182. const windowPages = Math.ceil(response.totalItem / PAGE_SIZE);
  183. if (windowPages > 200) throw new Error(`listing_review_window_too_large:${checkpoint.windowStart}:${checkpoint.windowEnd}:${response.totalItem}`);
  184. if (!response.comments.length && page <= windowPages) throw new Error(`listing_review_empty_page:${page}/${windowPages}`);
  185. const requests: Array<{ method: 'POST' | 'PUT'; path: string; body: unknown }> = [];
  186. for (const comment of response.comments) {
  187. checkpoint.commentsScanned += 1;
  188. const skuId = text(comment['skuid'] ?? comment['skuId']);
  189. const productId = skuToProduct.get(skuId);
  190. if (!productId) { checkpoint.commentsUnmatched += 1; continue; }
  191. const content = text(comment['content']);
  192. if (!content) { checkpoint.commentsUnmatched += 1; continue; }
  193. const reviewId = text(comment['commentId']);
  194. const reviewDate = dateIso(comment['creationTime']);
  195. const reviewKey = makeReviewKey({ platform: 'jd', productId, reviewId, content, reviewDate });
  196. const naturalKey = [workspaceId, 'jd', reviewKey].map(encodeURIComponent).join('|');
  197. const existing = existingByNaturalKey.get(naturalKey) ?? existingBySourceReviewId.get(reviewId);
  198. if (!existing && seen.has(naturalKey)) { checkpoint.commentsDuplicate += 1; continue; }
  199. const body = {
  200. naturalKey, workspaceId, platform: 'jd', productId, sourceReviewId: reviewId || null,
  201. reviewKey, rating: rating(comment['score']), content,
  202. reviewDate: reviewDate ? parseDate(reviewDate) : null,
  203. rawPayload: sanitizeComment(comment),
  204. };
  205. if (existing) {
  206. requests.push({ method: 'PUT', path: `/classes/${VOC_PARSE_CLASSES.review}/${existing.objectId}`, body });
  207. checkpoint.commentsUpdated += 1;
  208. } else {
  209. requests.push({ method: 'POST', path: `/classes/${VOC_PARSE_CLASSES.review}`, body });
  210. checkpoint.commentsCreated += 1;
  211. }
  212. seen.add(naturalKey);
  213. if (reviewId && existing) existingBySourceReviewId.set(reviewId, existing);
  214. matchedProducts.add(productId);
  215. checkpoint.commentsMatched += 1;
  216. }
  217. for (let index = 0; index < requests.length; index += 50) {
  218. await writeFmodeBatch(target, requests.slice(index, index + 50));
  219. }
  220. checkpoint.pagesProcessed += 1;
  221. pagesThisRun += 1;
  222. const windowFinished = page >= Math.max(1, windowPages);
  223. if (windowFinished) {
  224. checkpoint.windowsProcessed = (checkpoint.windowsProcessed ?? 0) + 1;
  225. checkpoint.windowStart = checkpoint.windowEnd;
  226. checkpoint.windowEnd = minJdDateTime(addJdDays(checkpoint.windowStart, windowDays), checkpoint.historyEnd);
  227. checkpoint.nextPage = 1;
  228. } else {
  229. checkpoint.nextPage = page + 1;
  230. }
  231. checkpoint.matchedProducts = [...matchedProducts].sort();
  232. checkpoint.updatedAt = new Date().toISOString();
  233. await persistCheckpoint(checkpointPath, checkpoint);
  234. if (checkpoint.pagesProcessed % 25 === 0 || windowFinished && (checkpoint.windowsProcessed ?? 0) % 25 === 0) {
  235. console.log(JSON.stringify({ event: 'listing_review_progress', window: checkpoint.windowsProcessed, windowStart: checkpoint.windowStart, page, windowPages, scanned: checkpoint.commentsScanned, matched: checkpoint.commentsMatched, created: checkpoint.commentsCreated, updated: checkpoint.commentsUpdated, matchedProducts: matchedProducts.size }));
  236. }
  237. await wait(delayMs);
  238. }
  239. if (checkpoint.windowStart && checkpoint.historyEnd && jdEpoch(checkpoint.windowStart) >= jdEpoch(checkpoint.historyEnd)) checkpoint.status = 'completed';
  240. checkpoint.updatedAt = new Date().toISOString();
  241. checkpoint.matchedProducts = [...matchedProducts].sort();
  242. await persistCheckpoint(checkpointPath, checkpoint);
  243. console.log(JSON.stringify({
  244. mode: checkpoint.status, workspaceId, scope, cohortProducts: cohortProductIds.size, mappedSkus: skuToProduct.size,
  245. totalItems: checkpoint.totalItems, totalPages: checkpoint.totalPages, pagesProcessed: checkpoint.pagesProcessed,
  246. commentsScanned: checkpoint.commentsScanned, commentsMatched: checkpoint.commentsMatched,
  247. commentsCreated: checkpoint.commentsCreated, commentsUpdated: checkpoint.commentsUpdated,
  248. commentsDuplicate: checkpoint.commentsDuplicate, commentsUnmatched: checkpoint.commentsUnmatched,
  249. matchedProducts: matchedProducts.size, checkpointPath,
  250. }, null, 2));
  251. }
  252. class JdJosReviewClient {
  253. constructor(private readonly appKey: string, private readonly appSecret: string, private readonly accessToken: string) {}
  254. async page(page: number, pageSize: number, filters: JsonRecord = {}): Promise<{ totalItem: number; comments: JsonRecord[] }> {
  255. for (let attempt = 0; attempt < 6; attempt += 1) {
  256. try {
  257. const params: Record<string, string> = {
  258. method: METHOD, access_token: this.accessToken, app_key: this.appKey,
  259. timestamp: jdTime(), v: '2.0', sign_method: 'md5',
  260. '360buy_param_json': JSON.stringify({ page, pageSize, ...filters }),
  261. };
  262. const plain = Object.keys(params).sort().map((key) => `${key}${params[key] ?? ''}`).join('');
  263. params['sign'] = createHash('md5').update(`${this.appSecret}${plain}${this.appSecret}`).digest('hex').toUpperCase();
  264. const response = await fetch(`https://api.jd.com/routerjson?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(30_000) });
  265. const body = await response.json() as JsonRecord;
  266. if (!response.ok) throw new Error(`jd_review_http_${response.status}`);
  267. const root = record(body[Object.keys(body)[0] ?? '']);
  268. if (text(root['code']) !== '0' || text(root['resultCode']) !== '200') {
  269. const detail = text(root['resultMessage'] ?? root['resultMsg'] ?? root['message'] ?? root['msg'] ?? root['errorMessage']);
  270. const diagnostic = JSON.stringify(Object.fromEntries(Object.entries(root).filter(([key]) => key !== 'comments'))).slice(0, 1_000);
  271. throw new Error(`jd_review_api_${text(root['code'])}_${text(root['resultCode'])}${detail ? `:${detail}` : ''}:${diagnostic}`);
  272. }
  273. return { totalItem: Math.max(0, Number(root['totalItem']) || 0), comments: list(root['comments']).map(record) };
  274. } catch (error) {
  275. if (attempt >= 5) throw error;
  276. await wait(Math.min(8_000, 500 * 2 ** attempt));
  277. }
  278. }
  279. throw new Error('jd_review_retry_exhausted');
  280. }
  281. }
  282. function buildSkuMap(rows: StoredSource[], cohortProductIds: Set<string>): Map<string, string> {
  283. const output = new Map<string, string>();
  284. for (const row of rows) {
  285. const source = row.payload;
  286. if (!cohortProductIds.has(source.productId)) continue;
  287. output.set(source.productId, source.productId);
  288. for (const sku of source.skus ?? []) if (sku.skuId) output.set(String(sku.skuId), source.productId);
  289. }
  290. return output;
  291. }
  292. function buildOwnSkuMap(ownProductIds: Set<string>): Map<string, string> {
  293. const output = new Map<string, string>();
  294. for (const productId of ownProductIds) output.set(productId, productId);
  295. return output;
  296. }
  297. function findSkuConflicts(rows: StoredSource[], cohortProductIds: Set<string>): string[] {
  298. const owners = new Map<string, string>(); const conflicts = new Set<string>();
  299. for (const row of rows) {
  300. const source = row.payload; if (!cohortProductIds.has(source.productId)) continue;
  301. for (const sku of source.skus ?? []) {
  302. const skuId = String(sku.skuId || ''); if (!skuId) continue;
  303. const previous = owners.get(skuId); if (previous && previous !== source.productId) conflicts.add(skuId); else owners.set(skuId, source.productId);
  304. }
  305. }
  306. return [...conflicts];
  307. }
  308. function sanitizeComment(comment: JsonRecord): JsonRecord {
  309. const keys = ['commentId', 'creationTime', 'content', 'skuName', 'score', 'skuid', 'images', 'isVenderReply', 'replyCount', 'usefulCount', 'skuImage', 'status', 'videos'];
  310. return Object.fromEntries(keys.filter((key) => comment[key] !== undefined).map((key) => [key, comment[key]]));
  311. }
  312. async function writeFmodeBatch(
  313. client: ParseRestClient,
  314. requests: Array<{ method: 'POST' | 'PUT' | 'DELETE'; path: string; body?: unknown }>,
  315. ): Promise<void> {
  316. // Fmode exposes Parse at /backend/{appId}/data, but its batch router expects
  317. // nested request paths to start at /data rather than repeat the full mount.
  318. const results = await client.request<Array<{ error?: { code?: number; error?: string } }>>('/batch', {
  319. method: 'POST',
  320. body: {
  321. requests: requests.map((request) => ({ ...request, path: `/data${request.path}` })),
  322. },
  323. });
  324. const failure = results.find((result) => result.error)?.error;
  325. if (failure) throw new Error(`listing_review_batch_${failure.code ?? 'unknown'}:${failure.error ?? 'failed'}`);
  326. }
  327. function storedDateIso(value: unknown): string | null {
  328. if (typeof value === 'string') return dateIso(value);
  329. const iso = text(record(value)['iso']);
  330. return iso ? dateIso(iso) : null;
  331. }
  332. function isWithinJdRange(value: unknown, start: string, endExclusive: string): boolean {
  333. const iso = storedDateIso(value);
  334. if (!iso) return false;
  335. const epoch = new Date(iso).valueOf();
  336. return epoch >= jdEpoch(normalizeJdDateTime(start)) && epoch < jdEpoch(normalizeJdDateTime(endExclusive));
  337. }
  338. function normalizeJdDateTime(value: string): string {
  339. if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) return value;
  340. const date = new Date(value);
  341. if (Number.isNaN(date.valueOf())) throw new Error(`listing_review_invalid_date:${value}`);
  342. return formatJdDateTime(date);
  343. }
  344. function jdEpoch(value: string): number {
  345. const epoch = new Date(`${value.replace(' ', 'T')}+08:00`).valueOf();
  346. if (Number.isNaN(epoch)) throw new Error(`listing_review_invalid_jd_date:${value}`);
  347. return epoch;
  348. }
  349. function addJdDays(value: string, days: number): string { return formatJdDateTime(new Date(jdEpoch(value) + days * 86_400_000)); }
  350. function minJdDateTime(left: string, right: string): string { return jdEpoch(left) <= jdEpoch(right) ? left : right; }
  351. function jdTomorrow(): string { return `${formatJdDateTime(new Date(Date.now() + 86_400_000)).slice(0, 10)} 00:00:00`; }
  352. function rating(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 && parsed <= 5 ? parsed : 0; }
  353. function dateIso(value: unknown): string | null { const parsed = Number(value); const date = Number.isFinite(parsed) ? new Date(parsed < 10_000_000_000 ? parsed * 1_000 : parsed) : new Date(String(value ?? '')); return Number.isNaN(date.valueOf()) ? null : date.toISOString(); }
  354. function record(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}; }
  355. function list(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
  356. function text(value: unknown): string { return value === null || value === undefined ? '' : String(value).trim(); }
  357. function wait(milliseconds: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
  358. function formatJdDateTime(date: Date): string { const parts = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''; return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`; }
  359. function jdTime(): string { return formatJdDateTime(new Date()); }
  360. async function readCheckpoint(path: string): Promise<Checkpoint | null> { try { return JSON.parse(await readFile(path, 'utf8')) as Checkpoint; } catch { return null; } }
  361. async function persistCheckpoint(path: string, checkpoint: Checkpoint): Promise<void> { await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(checkpoint, null, 2)); }
  362. main().catch((error) => { console.error(`[sync-jd-listing-reviews] ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });