twenty-import-csv 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/dist/browser-automation.d.ts +24 -0
- package/dist/browser-automation.d.ts.map +1 -0
- package/dist/browser-automation.js +295 -0
- package/dist/browser-automation.js.map +1 -0
- package/dist/cli-browser.d.ts +3 -0
- package/dist/cli-browser.d.ts.map +1 -0
- package/dist/cli-browser.js +134 -0
- package/dist/cli-browser.js.map +1 -0
- package/dist/cli-fixed.d.ts +3 -0
- package/dist/cli-fixed.d.ts.map +1 -0
- package/dist/cli-fixed.js +112 -0
- package/dist/cli-fixed.js.map +1 -0
- package/dist/cli-simple.d.ts +3 -0
- package/dist/cli-simple.d.ts.map +1 -0
- package/dist/cli-simple.js +167 -0
- package/dist/cli-simple.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +167 -0
- package/dist/cli.js.map +1 -0
- package/dist/load-graphql.d.ts +23 -0
- package/dist/load-graphql.d.ts.map +1 -0
- package/dist/load-graphql.js +239 -0
- package/dist/load-graphql.js.map +1 -0
- package/dist/load-old.d.ts +24 -0
- package/dist/load-old.d.ts.map +1 -0
- package/dist/load-old.js +183 -0
- package/dist/load-old.js.map +1 -0
- package/dist/load-real.d.ts +23 -0
- package/dist/load-real.d.ts.map +1 -0
- package/dist/load-real.js +202 -0
- package/dist/load-real.js.map +1 -0
- package/dist/load.d.ts +24 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +195 -0
- package/dist/load.js.map +1 -0
- package/dist/mapper.d.ts +16 -0
- package/dist/mapper.d.ts.map +1 -0
- package/dist/mapper.js +181 -0
- package/dist/mapper.js.map +1 -0
- package/dist/parser-broken.d.ts +11 -0
- package/dist/parser-broken.d.ts.map +1 -0
- package/dist/parser-broken.js +88 -0
- package/dist/parser-broken.js.map +1 -0
- package/dist/parser-old.d.ts +11 -0
- package/dist/parser-old.d.ts.map +1 -0
- package/dist/parser-old.js +90 -0
- package/dist/parser-old.js.map +1 -0
- package/dist/parser.d.ts +11 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +83 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporter.d.ts +15 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +144 -0
- package/dist/reporter.js.map +1 -0
- package/examples/contacts-mapping.txt +8 -0
- package/examples/contacts.csv +6 -0
- package/package.json +50 -0
- package/src/browser-automation.ts +350 -0
- package/src/cli-browser.ts +134 -0
- package/src/cli-simple.ts +158 -0
- package/src/cli.ts +159 -0
- package/src/load-graphql.ts +238 -0
- package/src/load-old.ts +177 -0
- package/src/load-real.ts +199 -0
- package/src/load.ts +197 -0
- package/src/mapper.ts +183 -0
- package/src/parser.ts +55 -0
- package/src/reporter.ts +131 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { chromium, Browser, Page, BrowserContext } from 'playwright';
|
|
2
|
+
import { ParsedRecord } from './parser';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
export interface BrowserImportResult {
|
|
7
|
+
success: number;
|
|
8
|
+
errors: number;
|
|
9
|
+
errorLog: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TwentyBrowserImporter {
|
|
13
|
+
private browser: Browser | null = null;
|
|
14
|
+
private context: BrowserContext | null = null;
|
|
15
|
+
private page: Page | null = null;
|
|
16
|
+
private twentyUrl: string;
|
|
17
|
+
|
|
18
|
+
constructor(twentyUrl: string) {
|
|
19
|
+
this.twentyUrl = twentyUrl.replace(/\/$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async initialize(): Promise<void> {
|
|
23
|
+
console.log('π ΠΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ Π±ΡΠ°ΡΠ·Π΅ΡΠ°...');
|
|
24
|
+
|
|
25
|
+
this.browser = await chromium.launch({
|
|
26
|
+
headless: false, // ΠΠΎΠΊΠ°Π·ΡΠ²Π°Π΅ΠΌ Π±ΡΠ°ΡΠ·Π΅Ρ Π΄Π»Ρ ΠΎΡΠ»Π°Π΄ΠΊΠΈ
|
|
27
|
+
slowMo: 100 // ΠΠ°ΠΌΠ΅Π΄Π»ΡΠ΅ΠΌ Π΄Π΅ΠΉΡΡΠ²ΠΈΡ Π΄Π»Ρ Π½Π°Π΄Π΅ΠΆΠ½ΠΎΡΡΠΈ
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
this.context = await this.browser.newContext({
|
|
31
|
+
viewport: { width: 1280, height: 720 }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
this.page = await this.context.newPage();
|
|
35
|
+
|
|
36
|
+
console.log('β
ΠΡΠ°ΡΠ·Π΅Ρ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async login(apiKey: string): Promise<void> {
|
|
40
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
41
|
+
|
|
42
|
+
console.log('π ΠΡ
ΠΎΠ΄ Π² Twenty CRM...');
|
|
43
|
+
|
|
44
|
+
await this.page.goto(`${this.twentyUrl}/auth/sign-in`);
|
|
45
|
+
await this.page.waitForLoadState('networkidle');
|
|
46
|
+
|
|
47
|
+
// ΠΡΠ΅ΠΌ ΡΠΎΡΠΌΡ Π²Ρ
ΠΎΠ΄Π°
|
|
48
|
+
await this.page.waitForSelector('input[type="email"], input[name="email"]', { timeout: 10000 });
|
|
49
|
+
|
|
50
|
+
// ΠΡΠ»ΠΈ Π΅ΡΡΡ API ΠΊΠ»ΡΡ, ΠΏΡΠΎΠ±ΡΠ΅ΠΌ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΡ Π΅Π³ΠΎ
|
|
51
|
+
if (apiKey) {
|
|
52
|
+
// ΠΡΠ΅ΠΌ ΠΏΠΎΠ»Π΅ Π΄Π»Ρ API ΠΊΠ»ΡΡΠ° ΠΈΠ»ΠΈ ΡΠΏΠ΅ΡΠΈΠ°Π»ΡΠ½ΡΠΉ Π²Ρ
ΠΎΠ΄
|
|
53
|
+
const apiKeySelector = await this.page.$('input[placeholder*="API"], input[name*="api"], input[type="password"]');
|
|
54
|
+
if (apiKeySelector) {
|
|
55
|
+
await apiKeySelector.fill(apiKey);
|
|
56
|
+
await this.page.click('button[type="submit"]');
|
|
57
|
+
await this.page.waitForLoadState('networkidle');
|
|
58
|
+
console.log('β
ΠΡ
ΠΎΠ΄ Π²ΡΠΏΠΎΠ»Π½Π΅Π½ ΡΠ΅ΡΠ΅Π· API ΠΊΠ»ΡΡ');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('β οΈ Π’ΡΠ΅Π±ΡΠ΅ΡΡΡ ΡΡΡΠ½ΠΎΠΉ Π²Ρ
ΠΎΠ΄. ΠΠΎΠΆΠ°Π»ΡΠΉΡΡΠ°, Π²ΠΎΠΉΠ΄ΠΈΡΠ΅ Π² ΡΠΈΡΡΠ΅ΠΌΡ...');
|
|
64
|
+
await this.page.waitForURL('**/dashboard', { timeout: 60000 });
|
|
65
|
+
console.log('β
ΠΡ
ΠΎΠ΄ Π²ΡΠΏΠΎΠ»Π½Π΅Π½');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async navigateToPeople(): Promise<void> {
|
|
69
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
70
|
+
|
|
71
|
+
console.log('π₯ ΠΠ΅ΡΠ΅Ρ
ΠΎΠ΄ ΠΊ ΡΠ°Π·Π΄Π΅Π»Ρ People...');
|
|
72
|
+
|
|
73
|
+
await this.page.goto(`${this.twentyUrl}/people`);
|
|
74
|
+
await this.page.waitForLoadState('networkidle');
|
|
75
|
+
|
|
76
|
+
// ΠΠ΄Π΅ΠΌ Π·Π°Π³ΡΡΠ·ΠΊΠΈ ΡΠ°Π±Π»ΠΈΡΡ ΠΈΠ»ΠΈ ΡΠΏΠΈΡΠΊΠ°
|
|
77
|
+
await this.page.waitForSelector('table, [data-testid="people-list"], .people-container', { timeout: 10000 });
|
|
78
|
+
|
|
79
|
+
console.log('β
Π Π°Π·Π΄Π΅Π» People Π·Π°Π³ΡΡΠΆΠ΅Π½');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async findImportButton(): Promise<boolean> {
|
|
83
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
84
|
+
|
|
85
|
+
console.log('π ΠΠΎΠΈΡΠΊ ΠΊΠ½ΠΎΠΏΠΊΠΈ ΠΈΠΌΠΏΠΎΡΡΠ°...');
|
|
86
|
+
|
|
87
|
+
const importSelectors = [
|
|
88
|
+
'button[data-testid="import-button"]',
|
|
89
|
+
'button[title*="Import"]',
|
|
90
|
+
'button:has-text("Import")',
|
|
91
|
+
'a[data-testid="import-link"]',
|
|
92
|
+
'a[title*="Import"]',
|
|
93
|
+
'a:has-text("Import")',
|
|
94
|
+
'[data-testid="add-button"]', // ΠΠ½ΠΎΠΏΠΊΠ° Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ
|
|
95
|
+
'button:has-text("Add")'
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
for (const selector of importSelectors) {
|
|
99
|
+
try {
|
|
100
|
+
const element = await this.page.$(selector);
|
|
101
|
+
if (element) {
|
|
102
|
+
console.log(`β
ΠΠ°ΠΉΠ΄Π΅Π½Π° ΠΊΠ½ΠΎΠΏΠΊΠ° ΠΈΠΌΠΏΠΎΡΡΠ°: ${selector}`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΠΏΠΎΠΈΡΠΊ
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('β ΠΠ½ΠΎΠΏΠΊΠ° ΠΈΠΌΠΏΠΎΡΡΠ° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°');
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async importCSV(csvFilePath: string): Promise<void> {
|
|
115
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
116
|
+
|
|
117
|
+
console.log(`π ΠΠΌΠΏΠΎΡΡ CSV ΡΠ°ΠΉΠ»Π°: ${csvFilePath}`);
|
|
118
|
+
|
|
119
|
+
// ΠΡΠΎΠ±ΡΠ΅ΠΌ Π½Π°ΠΉΡΠΈ ΠΈ Π½Π°ΠΆΠ°ΡΡ ΠΊΠ½ΠΎΠΏΠΊΡ ΠΈΠΌΠΏΠΎΡΡΠ°
|
|
120
|
+
const hasImportButton = await this.findImportButton();
|
|
121
|
+
|
|
122
|
+
if (hasImportButton) {
|
|
123
|
+
// ΠΠ°ΠΆΠΈΠΌΠ°Π΅ΠΌ ΠΊΠ½ΠΎΠΏΠΊΡ ΠΈΠΌΠΏΠΎΡΡΠ°
|
|
124
|
+
await this.page.click('button[data-testid="import-button"], button:has-text("Import"), [data-testid="add-button"]');
|
|
125
|
+
await this.page.waitForTimeout(1000);
|
|
126
|
+
|
|
127
|
+
// ΠΡΠ΅ΠΌ ΠΏΠΎΠ»Π΅ Π΄Π»Ρ Π·Π°Π³ΡΡΠ·ΠΊΠΈ ΡΠ°ΠΉΠ»Π°
|
|
128
|
+
const fileInputSelectors = [
|
|
129
|
+
'input[type="file"]',
|
|
130
|
+
'input[accept*=".csv"]',
|
|
131
|
+
'[data-testid="file-input"]'
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const selector of fileInputSelectors) {
|
|
135
|
+
try {
|
|
136
|
+
const fileInput = await this.page.$(selector);
|
|
137
|
+
if (fileInput) {
|
|
138
|
+
await fileInput.setInputFiles(csvFilePath);
|
|
139
|
+
console.log('β
Π€Π°ΠΉΠ» Π·Π°Π³ΡΡΠΆΠ΅Π½');
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΠΏΠΎΠΈΡΠΊ
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ΠΡΠ΅ΠΌ ΠΊΠ½ΠΎΠΏΠΊΡ ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΡ ΠΈΠΌΠΏΠΎΡΡΠ°
|
|
148
|
+
const submitSelectors = [
|
|
149
|
+
'button[data-testid="import-submit"]',
|
|
150
|
+
'button:has-text("Import")',
|
|
151
|
+
'button:has-text("Upload")',
|
|
152
|
+
'button[type="submit"]'
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const selector of submitSelectors) {
|
|
156
|
+
try {
|
|
157
|
+
const submitButton = await this.page.$(selector);
|
|
158
|
+
if (submitButton) {
|
|
159
|
+
await submitButton.click();
|
|
160
|
+
console.log('β
ΠΠΌΠΏΠΎΡΡ Π·Π°ΠΏΡΡΠ΅Π½');
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΠΏΠΎΠΈΡΠΊ
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ΠΠ΄Π΅ΠΌ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΡ ΠΈΠΌΠΏΠΎΡΡΠ°
|
|
169
|
+
await this.page.waitForTimeout(5000);
|
|
170
|
+
} else {
|
|
171
|
+
console.log('β ΠΠ²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈΠΉ ΠΈΠΌΠΏΠΎΡΡ Π½Π΅Π²ΠΎΠ·ΠΌΠΎΠΆΠ΅Π½');
|
|
172
|
+
console.log('π‘ Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°ΡΠΈΠΈ:');
|
|
173
|
+
console.log(' 1. ΠΠΌΠΏΠΎΡΡΠΈΡΡΠΉΡΠ΅ Π΄Π°Π½Π½ΡΠ΅ Π²ΡΡΡΠ½ΡΡ ΡΠ΅ΡΠ΅Π· UI');
|
|
174
|
+
console.log(' 2. ΠΡΠΏΠΎΠ»ΡΠ·ΡΠΉΡΠ΅ ΡΠΎΡΠΌΠ°Ρ CSV, ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°Π΅ΠΌΡΠΉ Twenty CRM');
|
|
175
|
+
console.log(' 3. ΠΡΠΎΠ²Π΅ΡΡΡΠ΅ ΠΏΡΠ°Π²Π° Π΄ΠΎΡΡΡΠΏΠ° Π΄Π»Ρ ΠΈΠΌΠΏΠΎΡΡΠ°');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async importRecords(records: ParsedRecord[]): Promise<BrowserImportResult> {
|
|
180
|
+
const result: BrowserImportResult = {
|
|
181
|
+
success: 0,
|
|
182
|
+
errors: 0,
|
|
183
|
+
errorLog: []
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
187
|
+
|
|
188
|
+
console.log(`π₯ ΠΠΌΠΏΠΎΡΡ ${records.length} Π·Π°ΠΏΠΈΡΠ΅ΠΉ...`);
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < records.length; i++) {
|
|
191
|
+
const record = records[i];
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
console.log(`π ΠΠΌΠΏΠΎΡΡ Π·Π°ΠΏΠΈΡΠΈ ${i + 1}/${records.length}`);
|
|
195
|
+
|
|
196
|
+
// ΠΠ΅ΡΠ΅Ρ
ΠΎΠ΄ΠΈΠΌ ΠΊ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ Π½ΠΎΠ²ΠΎΠΉ Π·Π°ΠΏΠΈΡΠΈ
|
|
197
|
+
await this.navigateToPeople();
|
|
198
|
+
|
|
199
|
+
// ΠΠ°Ρ
ΠΎΠ΄ΠΈΠΌ ΠΊΠ½ΠΎΠΏΠΊΡ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ
|
|
200
|
+
const addButton = await this.page.$('button[data-testid="add-button"], button:has-text("Add"), button:has-text("New")');
|
|
201
|
+
if (addButton) {
|
|
202
|
+
await addButton.click();
|
|
203
|
+
await this.page.waitForTimeout(1000);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΡΠΎΡΠΌΡ
|
|
207
|
+
await this.fillPersonForm(record);
|
|
208
|
+
|
|
209
|
+
// Π‘ΠΎΡ
ΡΠ°Π½ΡΠ΅ΠΌ Π·Π°ΠΏΠΈΡΡ
|
|
210
|
+
await this.savePersonForm();
|
|
211
|
+
|
|
212
|
+
result.success++;
|
|
213
|
+
console.log(`β
ΠΠ°ΠΏΠΈΡΡ ${i + 1} ΡΡΠΏΠ΅ΡΠ½ΠΎ ΠΈΠΌΠΏΠΎΡΡΠΈΡΠΎΠ²Π°Π½Π°`);
|
|
214
|
+
|
|
215
|
+
} catch (error) {
|
|
216
|
+
result.errors++;
|
|
217
|
+
const errorMsg = `ΠΠ°ΠΏΠΈΡΡ ${i + 1}: ${error}`;
|
|
218
|
+
result.errorLog.push(errorMsg);
|
|
219
|
+
console.error(`β ${errorMsg}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async fillPersonForm(record: ParsedRecord): Promise<void> {
|
|
227
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
228
|
+
|
|
229
|
+
// ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΠΈΠΌΡ
|
|
230
|
+
if (record['name.firstName']) {
|
|
231
|
+
const firstNameField = await this.page.$('input[name*="firstName"], input[placeholder*="First"], input[data-testid*="first"]');
|
|
232
|
+
if (firstNameField) {
|
|
233
|
+
await firstNameField.fill(record['name.firstName']);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (record['name.lastName']) {
|
|
238
|
+
const lastNameField = await this.page.$('input[name*="lastName"], input[placeholder*="Last"], input[data-testid*="last"]');
|
|
239
|
+
if (lastNameField) {
|
|
240
|
+
await lastNameField.fill(record['name.lastName']);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ email
|
|
245
|
+
if (record.email) {
|
|
246
|
+
const emailField = await this.page.$('input[name*="email"], input[type="email"], input[placeholder*="Email"]');
|
|
247
|
+
if (emailField) {
|
|
248
|
+
await emailField.fill(record.email);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ ΡΠ΅Π»Π΅ΡΠΎΠ½
|
|
253
|
+
if (record.phone) {
|
|
254
|
+
const phoneField = await this.page.$('input[name*="phone"], input[type="tel"], input[placeholder*="Phone"]');
|
|
255
|
+
if (phoneField) {
|
|
256
|
+
await phoneField.fill(record.phone);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ΠΠ°ΠΏΠΎΠ»Π½ΡΠ΅ΠΌ Π΄ΠΎΠ»ΠΆΠ½ΠΎΡΡΡ
|
|
261
|
+
if (record.jobTitle) {
|
|
262
|
+
const jobTitleField = await this.page.$('input[name*="jobTitle"], input[placeholder*="Job"], input[placeholder*="Position"]');
|
|
263
|
+
if (jobTitleField) {
|
|
264
|
+
await jobTitleField.fill(record.jobTitle);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async savePersonForm(): Promise<void> {
|
|
270
|
+
if (!this.page) throw new Error('ΠΡΠ°ΡΠ·Π΅Ρ Π½Π΅ ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·ΠΈΡΠΎΠ²Π°Π½');
|
|
271
|
+
|
|
272
|
+
// ΠΡΠ΅ΠΌ ΠΊΠ½ΠΎΠΏΠΊΡ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΡ
|
|
273
|
+
const saveSelectors = [
|
|
274
|
+
'button[data-testid="save-button"]',
|
|
275
|
+
'button:has-text("Save")',
|
|
276
|
+
'button:has-text("Create")',
|
|
277
|
+
'button[type="submit"]'
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
for (const selector of saveSelectors) {
|
|
281
|
+
try {
|
|
282
|
+
const saveButton = await this.page.$(selector);
|
|
283
|
+
if (saveButton) {
|
|
284
|
+
await saveButton.click();
|
|
285
|
+
await this.page.waitForTimeout(2000);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΠΏΠΎΠΈΡΠΊ
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async cleanup(): Promise<void> {
|
|
295
|
+
console.log('π§Ή ΠΡΠΈΡΡΠΊΠ° ΡΠ΅ΡΡΡΡΠΎΠ²...');
|
|
296
|
+
|
|
297
|
+
if (this.context) {
|
|
298
|
+
await this.context.close();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (this.browser) {
|
|
302
|
+
await this.browser.close();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log('β
Π Π΅ΡΡΡΡΡ ΠΎΡΠΈΡΠ΅Π½Ρ');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function importViaBrowser(
|
|
310
|
+
records: ParsedRecord[],
|
|
311
|
+
objectType: string,
|
|
312
|
+
twentyUrl: string,
|
|
313
|
+
apiKey: string,
|
|
314
|
+
csvFilePath?: string
|
|
315
|
+
): Promise<BrowserImportResult> {
|
|
316
|
+
const importer = new TwentyBrowserImporter(twentyUrl);
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
await importer.initialize();
|
|
320
|
+
await importer.login(apiKey);
|
|
321
|
+
|
|
322
|
+
if (csvFilePath) {
|
|
323
|
+
// ΠΡΠΎΠ±ΡΠ΅ΠΌ ΠΈΠΌΠΏΠΎΡΡ ΡΠ΅ΡΠ΅Π· CSV ΡΠ°ΠΉΠ»
|
|
324
|
+
await importer.navigateToPeople();
|
|
325
|
+
await importer.importCSV(csvFilePath);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
success: records.length,
|
|
329
|
+
errors: 0,
|
|
330
|
+
errorLog: []
|
|
331
|
+
};
|
|
332
|
+
} else {
|
|
333
|
+
// ΠΠΌΠΏΠΎΡΡ ΡΠ΅ΡΠ΅Π· Π·Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΡΠΎΡΠΌ
|
|
334
|
+
if (objectType === 'people') {
|
|
335
|
+
return await importer.importRecords(records);
|
|
336
|
+
} else {
|
|
337
|
+
throw new Error(`Π’ΠΈΠΏ ΠΎΠ±ΡΠ΅ΠΊΡΠ° ${objectType} ΠΏΠΎΠΊΠ° Π½Π΅ ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠΈΠ²Π°Π΅ΡΡΡ`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return {
|
|
343
|
+
success: 0,
|
|
344
|
+
errors: records.length,
|
|
345
|
+
errorLog: [`ΠΠ±ΡΠ°Ρ ΠΎΡΠΈΠ±ΠΊΠ°: ${error}`]
|
|
346
|
+
};
|
|
347
|
+
} finally {
|
|
348
|
+
await importer.cleanup();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { parseCSV } from './parser';
|
|
5
|
+
import { importViaBrowser } from './browser-automation';
|
|
6
|
+
import { showProgress, generateReport } from './reporter';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
|
|
10
|
+
interface Options {
|
|
11
|
+
file: string;
|
|
12
|
+
object: string;
|
|
13
|
+
twentyUrl: string;
|
|
14
|
+
twentyKey: string;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
browser: boolean;
|
|
17
|
+
batch?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const program = new Command();
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name('twenty-import-csv')
|
|
24
|
+
.description('Universal CSV import tool for Twenty CRM (Browser Automation)')
|
|
25
|
+
.version('1.0.0');
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.requiredOption('-f, --file <path>', 'CSV file to import')
|
|
29
|
+
.requiredOption('-o, --object <type>', 'Twenty object type (people, companies, opportunities, etc.)')
|
|
30
|
+
.requiredOption('-u, --twenty-url <url>', 'Twenty CRM URL')
|
|
31
|
+
.requiredOption('-k, --twenty-key <key>', 'Twenty CRM API key')
|
|
32
|
+
.option('-d, --dry-run', 'Preview import without writing data', false)
|
|
33
|
+
.option('-b, --browser', 'Use browser automation (recommended)', true)
|
|
34
|
+
.option('--batch <number>', 'Batch size for API calls', '60')
|
|
35
|
+
.parse();
|
|
36
|
+
|
|
37
|
+
const options = program.opts() as Options;
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
try {
|
|
41
|
+
console.log('π Twenty CSV Import Tool (Browser Automation)');
|
|
42
|
+
console.log(`π File: ${options.file}`);
|
|
43
|
+
console.log(`π― Object: ${options.object}`);
|
|
44
|
+
console.log(`π Twenty URL: ${options.twentyUrl}`);
|
|
45
|
+
console.log(`π Browser Mode: ${options.browser ? 'YES' : 'NO'}`);
|
|
46
|
+
console.log(`π§ͺ Dry Run: ${options.dryRun ? 'YES' : 'NO'}`);
|
|
47
|
+
|
|
48
|
+
// Check if file exists
|
|
49
|
+
if (!fs.existsSync(options.file)) {
|
|
50
|
+
console.error(`β Error: File ${options.file} not found`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse CSV
|
|
55
|
+
console.log('\nπ Parsing CSV file...');
|
|
56
|
+
const data = await parseCSV(options.file);
|
|
57
|
+
console.log(`β
Parsed ${data.length} records`);
|
|
58
|
+
|
|
59
|
+
// Simple field mapping for people
|
|
60
|
+
const mappedData = data.map((record: any) => {
|
|
61
|
+
const mapped: any = {};
|
|
62
|
+
|
|
63
|
+
// Auto-map common fields for people
|
|
64
|
+
if (options.object === 'people') {
|
|
65
|
+
mapped['name.firstName'] = record.first_name || record.firstName || '';
|
|
66
|
+
mapped['name.lastName'] = record.last_name || record.lastName || '';
|
|
67
|
+
mapped['email'] = record.email || '';
|
|
68
|
+
mapped['phone'] = record.phone || '';
|
|
69
|
+
mapped['jobTitle'] = record.job_title || record.jobTitle || '';
|
|
70
|
+
mapped['city'] = record.city || '';
|
|
71
|
+
mapped['country'] = record.country || '';
|
|
72
|
+
} else {
|
|
73
|
+
// For other objects, just pass through
|
|
74
|
+
Object.assign(mapped, record);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return mapped;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Show preview for dry run
|
|
81
|
+
if (options.dryRun) {
|
|
82
|
+
console.log('\nπ DRY RUN - Preview (first 5 records):');
|
|
83
|
+
console.log(JSON.stringify(mappedData.slice(0, 5), null, 2));
|
|
84
|
+
console.log(`\nπ Summary: ${mappedData.length} records ready for import`);
|
|
85
|
+
console.log('\nπ Browser automation will be used for import.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!options.browser) {
|
|
90
|
+
console.log('\nβ Browser automation is required for Twenty CRM');
|
|
91
|
+
console.log('π‘ Twenty CRM does not provide a proper REST API');
|
|
92
|
+
console.log('π‘ Use --browser flag to enable browser automation');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Import data using browser automation
|
|
97
|
+
console.log('\nπ Starting browser automation...');
|
|
98
|
+
const progressBar = showProgress(mappedData.length);
|
|
99
|
+
|
|
100
|
+
const result = await importViaBrowser(
|
|
101
|
+
mappedData,
|
|
102
|
+
options.object,
|
|
103
|
+
options.twentyUrl,
|
|
104
|
+
options.twentyKey,
|
|
105
|
+
options.file // Pass CSV file for direct import
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
progressBar.stop();
|
|
109
|
+
|
|
110
|
+
// Generate report
|
|
111
|
+
await generateReport(result, options.object);
|
|
112
|
+
|
|
113
|
+
console.log('\nβ
Import completed!');
|
|
114
|
+
console.log(`π Success: ${result.success}`);
|
|
115
|
+
console.log(`β Errors: ${result.errors}`);
|
|
116
|
+
|
|
117
|
+
if (result.errors > 0) {
|
|
118
|
+
console.log(`π Error log: import-errors-${Date.now()}.log`);
|
|
119
|
+
console.log('\nπ Error details:');
|
|
120
|
+
result.errorLog.forEach(error => {
|
|
121
|
+
console.log(` β ${error}`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('\nπ‘ Browser automation completed!');
|
|
126
|
+
console.log('π‘ Please check Twenty CRM to verify the imported data.');
|
|
127
|
+
|
|
128
|
+
} catch (error: any) {
|
|
129
|
+
console.error('β Import failed:', error.message);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
main();
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as Papa from 'papaparse';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
interface Options {
|
|
10
|
+
file: string;
|
|
11
|
+
object: string;
|
|
12
|
+
twentyUrl: string;
|
|
13
|
+
twentyKey: string;
|
|
14
|
+
dryRun: boolean;
|
|
15
|
+
batch?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const program = new Command();
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.name('twenty-import-csv')
|
|
22
|
+
.description('Universal CSV import tool for Twenty CRM')
|
|
23
|
+
.version('1.0.0');
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.requiredOption('-f, --file <path>', 'CSV file to import')
|
|
27
|
+
.requiredOption('-o, --object <type>', 'Twenty object type (people, companies, opportunities, etc.)')
|
|
28
|
+
.requiredOption('-u, --twenty-url <url>', 'Twenty CRM URL')
|
|
29
|
+
.requiredOption('-k, --twenty-key <key>', 'Twenty CRM API key')
|
|
30
|
+
.option('-d, --dry-run', 'Preview import without writing data', false)
|
|
31
|
+
.option('-b, --batch <number>', 'Batch size for API calls', '60')
|
|
32
|
+
.parse();
|
|
33
|
+
|
|
34
|
+
const options = program.opts() as Options;
|
|
35
|
+
|
|
36
|
+
async function parseCSV(filePath: string): Promise<any[]> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
39
|
+
|
|
40
|
+
Papa.parse(fileContent, {
|
|
41
|
+
header: true,
|
|
42
|
+
skipEmptyLines: true,
|
|
43
|
+
complete: (results) => {
|
|
44
|
+
if (results.errors.length > 0) {
|
|
45
|
+
console.warn(`β οΈ CSV parsing warnings: ${results.errors.length}`);
|
|
46
|
+
}
|
|
47
|
+
resolve(results.data);
|
|
48
|
+
},
|
|
49
|
+
error: (error: any) => {
|
|
50
|
+
reject(new Error(`CSV parsing failed: ${error.message}`));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
try {
|
|
58
|
+
console.log('π Twenty CSV Import Tool');
|
|
59
|
+
console.log(`π File: ${options.file}`);
|
|
60
|
+
console.log(`π― Object: ${options.object}`);
|
|
61
|
+
console.log(`π Twenty URL: ${options.twentyUrl}`);
|
|
62
|
+
console.log(`π§ͺ Dry Run: ${options.dryRun ? 'YES' : 'NO'}`);
|
|
63
|
+
|
|
64
|
+
// Check if file exists
|
|
65
|
+
if (!fs.existsSync(options.file)) {
|
|
66
|
+
console.error(`β Error: File ${options.file} not found`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Parse CSV
|
|
71
|
+
console.log('\nπ Parsing CSV file...');
|
|
72
|
+
const data = await parseCSV(options.file);
|
|
73
|
+
console.log(`β
Parsed ${data.length} records`);
|
|
74
|
+
|
|
75
|
+
// Simple field mapping for people
|
|
76
|
+
const mappedData = data.map((record: any) => {
|
|
77
|
+
const mapped: any = {};
|
|
78
|
+
|
|
79
|
+
// Auto-map common fields for people
|
|
80
|
+
if (options.object === 'people') {
|
|
81
|
+
mapped['name.firstName'] = record.first_name || record.firstName || '';
|
|
82
|
+
mapped['name.lastName'] = record.last_name || record.lastName || '';
|
|
83
|
+
mapped['email'] = record.email || '';
|
|
84
|
+
mapped['phone'] = record.phone || '';
|
|
85
|
+
mapped['jobTitle'] = record.job_title || record.jobTitle || '';
|
|
86
|
+
mapped['city'] = record.city || '';
|
|
87
|
+
mapped['country'] = record.country || '';
|
|
88
|
+
} else {
|
|
89
|
+
// For other objects, just pass through
|
|
90
|
+
Object.assign(mapped, record);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return mapped;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Show preview for dry run
|
|
97
|
+
if (options.dryRun) {
|
|
98
|
+
console.log('\nπ DRY RUN - Preview (first 5 records):');
|
|
99
|
+
console.log(JSON.stringify(mappedData.slice(0, 5), null, 2));
|
|
100
|
+
console.log(`\nπ Summary: ${mappedData.length} records ready for import`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Load data to Twenty
|
|
105
|
+
console.log('\nπ€ Loading data to Twenty CRM...');
|
|
106
|
+
const batchSize = parseInt(String(options.batch || '60'));
|
|
107
|
+
const client = axios.create({
|
|
108
|
+
baseURL: `${options.twentyUrl.replace(/\/$/, '')}/rest`,
|
|
109
|
+
headers: {
|
|
110
|
+
'Authorization': `Bearer ${options.twentyKey}`,
|
|
111
|
+
'Content-Type': 'application/json'
|
|
112
|
+
},
|
|
113
|
+
timeout: 30000
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
let successCount = 0;
|
|
117
|
+
let errorCount = 0;
|
|
118
|
+
|
|
119
|
+
// Process in batches
|
|
120
|
+
for (let i = 0; i < mappedData.length; i += batchSize) {
|
|
121
|
+
const batch = mappedData.slice(i, i + batchSize);
|
|
122
|
+
const batchNumber = Math.floor(i / batchSize) + 1;
|
|
123
|
+
const totalBatches = Math.ceil(mappedData.length / batchSize);
|
|
124
|
+
|
|
125
|
+
console.log(`π€ Batch ${batchNumber}/${totalBatches}: ${batch.length} records`);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const response = await client.post(`/${options.object}`, {
|
|
129
|
+
data: batch
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (response.status === 200) {
|
|
133
|
+
successCount += batch.length;
|
|
134
|
+
console.log(`β
Batch ${batchNumber} imported successfully`);
|
|
135
|
+
} else {
|
|
136
|
+
throw new Error(`HTTP ${response.status}`);
|
|
137
|
+
}
|
|
138
|
+
} catch (error: any) {
|
|
139
|
+
errorCount += batch.length;
|
|
140
|
+
console.error(`β Batch ${batchNumber} failed:`, error.response?.data || error.message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Small delay to avoid rate limiting
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('\nβ
Import completed!');
|
|
148
|
+
console.log(`π Success: ${successCount}`);
|
|
149
|
+
console.log(`β Errors: ${errorCount}`);
|
|
150
|
+
console.log(`π Success rate: ${((successCount / (successCount + errorCount)) * 100).toFixed(2)}%`);
|
|
151
|
+
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error('β Import failed:', (error as Error).message);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main();
|