googleformsubmitter 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/.data/form-cache-1FAIpQLSc1aiEAhxWCMSNjZ1asGDtXV_mT8vTiJRquiqfdjsoMkKT9tw.json +16 -0
- package/.env.example +4 -0
- package/CLAUDE.md +106 -0
- package/GoogleFormSubmitter.ts +319 -0
- package/README.md +115 -0
- package/bun.lock +328 -0
- package/create-form.ts +410 -0
- package/index.ts +2 -0
- package/package.json +26 -0
- package/test.ts +139 -0
- package/tsconfig.json +30 -0
- package/types.ts +18 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"requestId": "1876174487",
|
|
3
|
+
"fullName": "1192130047",
|
|
4
|
+
"birthDate": "1354328376",
|
|
5
|
+
"grade": "1867801269",
|
|
6
|
+
"location": "1504377681",
|
|
7
|
+
"rate": "282114114",
|
|
8
|
+
"nds": "1347448341",
|
|
9
|
+
"exitDate": "1161802420",
|
|
10
|
+
"vacation": "1977793041",
|
|
11
|
+
"inStaff": "447126466",
|
|
12
|
+
"cvFile": "1595274093",
|
|
13
|
+
"company": "553187985",
|
|
14
|
+
"checklist": "1954006978",
|
|
15
|
+
"comment": "998553124"
|
|
16
|
+
}
|
package/.env.example
ADDED
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
|
|
2
|
+
Default to using Bun instead of Node.js.
|
|
3
|
+
|
|
4
|
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
5
|
+
- Use `bun test` instead of `jest` or `vitest`
|
|
6
|
+
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
|
7
|
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
8
|
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
9
|
+
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
|
10
|
+
- Bun automatically loads .env, so don't use dotenv.
|
|
11
|
+
|
|
12
|
+
## APIs
|
|
13
|
+
|
|
14
|
+
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
|
15
|
+
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
16
|
+
- `Bun.redis` for Redis. Don't use `ioredis`.
|
|
17
|
+
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
|
18
|
+
- `WebSocket` is built-in. Don't use `ws`.
|
|
19
|
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
20
|
+
- Bun.$`ls` instead of execa.
|
|
21
|
+
|
|
22
|
+
## Testing
|
|
23
|
+
|
|
24
|
+
Use `bun test` to run tests.
|
|
25
|
+
|
|
26
|
+
```ts#index.test.ts
|
|
27
|
+
import { test, expect } from "bun:test";
|
|
28
|
+
|
|
29
|
+
test("hello world", () => {
|
|
30
|
+
expect(1).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Frontend
|
|
35
|
+
|
|
36
|
+
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
|
37
|
+
|
|
38
|
+
Server:
|
|
39
|
+
|
|
40
|
+
```ts#index.ts
|
|
41
|
+
import index from "./index.html"
|
|
42
|
+
|
|
43
|
+
Bun.serve({
|
|
44
|
+
routes: {
|
|
45
|
+
"/": index,
|
|
46
|
+
"/api/users/:id": {
|
|
47
|
+
GET: (req) => {
|
|
48
|
+
return new Response(JSON.stringify({ id: req.params.id }));
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
// optional websocket support
|
|
53
|
+
websocket: {
|
|
54
|
+
open: (ws) => {
|
|
55
|
+
ws.send("Hello, world!");
|
|
56
|
+
},
|
|
57
|
+
message: (ws, message) => {
|
|
58
|
+
ws.send(message);
|
|
59
|
+
},
|
|
60
|
+
close: (ws) => {
|
|
61
|
+
// handle close
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
development: {
|
|
65
|
+
hmr: true,
|
|
66
|
+
console: true,
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
|
72
|
+
|
|
73
|
+
```html#index.html
|
|
74
|
+
<html>
|
|
75
|
+
<body>
|
|
76
|
+
<h1>Hello, world!</h1>
|
|
77
|
+
<script type="module" src="./frontend.tsx"></script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
With the following `frontend.tsx`:
|
|
83
|
+
|
|
84
|
+
```tsx#frontend.tsx
|
|
85
|
+
import React from "react";
|
|
86
|
+
import { createRoot } from "react-dom/client";
|
|
87
|
+
|
|
88
|
+
// import .css files directly and it works
|
|
89
|
+
import './index.css';
|
|
90
|
+
|
|
91
|
+
const root = createRoot(document.body);
|
|
92
|
+
|
|
93
|
+
export default function Frontend() {
|
|
94
|
+
return <h1>Hello, world!</h1>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
root.render(<Frontend />);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then, run index.ts
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
bun --hot ./index.ts
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import Ajv from 'ajv';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import type { FormSchemaMapping, SubmissionResult } from './types';
|
|
6
|
+
|
|
7
|
+
const ajv = new Ajv({ allErrors: true });
|
|
8
|
+
|
|
9
|
+
export class GoogleFormSubmitter {
|
|
10
|
+
private formUrl: string;
|
|
11
|
+
private jsonSchema: Record<string, any>;
|
|
12
|
+
private mappingSchema: FormSchemaMapping;
|
|
13
|
+
private cdpUrl: string;
|
|
14
|
+
private cacheFilePath: string;
|
|
15
|
+
private entryIdCache: Record<string, string> | null = null;
|
|
16
|
+
private validateFn: ReturnType<typeof ajv.compile>;
|
|
17
|
+
private cookies?: Array<any>;
|
|
18
|
+
|
|
19
|
+
constructor(options: {
|
|
20
|
+
formUrl: string;
|
|
21
|
+
jsonSchema: Record<string, any>;
|
|
22
|
+
mappingSchema: FormSchemaMapping;
|
|
23
|
+
cdpUrl?: string;
|
|
24
|
+
cacheDir?: string;
|
|
25
|
+
cookies?: Array<any>;
|
|
26
|
+
}) {
|
|
27
|
+
this.formUrl = options.formUrl;
|
|
28
|
+
this.jsonSchema = options.jsonSchema;
|
|
29
|
+
this.mappingSchema = options.mappingSchema;
|
|
30
|
+
this.cdpUrl = options.cdpUrl || 'ws://127.0.0.1:9222/';
|
|
31
|
+
this.cookies = options.cookies;
|
|
32
|
+
|
|
33
|
+
// Compile JSON Schema validation function
|
|
34
|
+
this.validateFn = ajv.compile(this.jsonSchema);
|
|
35
|
+
|
|
36
|
+
// Compute a hash or safe name for the cache file based on the form URL
|
|
37
|
+
const formIdMatch = this.formUrl.match(/\/d\/e\/([a-zA-Z0-9_-]+)/) || this.formUrl.match(/\/d\/([a-zA-Z0-9_-]+)/);
|
|
38
|
+
const formHash = formIdMatch ? formIdMatch[1] : Buffer.from(this.formUrl).toString('base64').substring(0, 16);
|
|
39
|
+
|
|
40
|
+
const cacheDir = options.cacheDir || path.join(process.cwd(), '.data');
|
|
41
|
+
this.cacheFilePath = path.join(cacheDir, `form-cache-${formHash}.json`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lazily loads and returns the resolved entryId mapping cache.
|
|
46
|
+
* If cache file exists, reads it. Otherwise, launches Lightpanda CDP,
|
|
47
|
+
* scans the DOM, caches it, and closes the browser.
|
|
48
|
+
*/
|
|
49
|
+
private async getOrResolveEntryIds(): Promise<Record<string, string>> {
|
|
50
|
+
if (this.entryIdCache) {
|
|
51
|
+
return this.entryIdCache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 1. Try reading from cache file
|
|
55
|
+
try {
|
|
56
|
+
const content = await fs.readFile(this.cacheFilePath, 'utf-8');
|
|
57
|
+
this.entryIdCache = JSON.parse(content);
|
|
58
|
+
console.log(`[GoogleFormSubmitter] Loaded entry ID mapping cache from: ${this.cacheFilePath}`);
|
|
59
|
+
return this.entryIdCache!;
|
|
60
|
+
} catch {
|
|
61
|
+
// Cache file doesn't exist, proceed to resolve
|
|
62
|
+
console.log(`[GoogleFormSubmitter] Cache not found. Resolving entry IDs dynamically via Lightpanda...`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Launch browser to inspect DOM
|
|
66
|
+
let browser;
|
|
67
|
+
let context;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
console.log(`[GoogleFormSubmitter] Connecting to CDP server at ${this.cdpUrl}...`);
|
|
71
|
+
browser = await chromium.connectOverCDP(this.cdpUrl, { timeout: 5000 });
|
|
72
|
+
context = await browser.newContext();
|
|
73
|
+
} catch (e: any) {
|
|
74
|
+
console.warn(`[GoogleFormSubmitter] CDP connection to Lightpanda failed: ${e.message}. Falling back to native headless Chromium...`);
|
|
75
|
+
browser = await chromium.launch({ headless: true });
|
|
76
|
+
context = await browser.newContext();
|
|
77
|
+
}
|
|
78
|
+
if (this.cookies && this.cookies.length > 0) {
|
|
79
|
+
await context.addCookies(this.cookies);
|
|
80
|
+
console.log(`[GoogleFormSubmitter] Injected ${this.cookies.length} cookies into browser context.`);
|
|
81
|
+
}
|
|
82
|
+
const page = await context.newPage();
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await page.goto(this.formUrl);
|
|
86
|
+
// Wait for fields to load
|
|
87
|
+
await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
|
|
88
|
+
|
|
89
|
+
// Scan DOM for question containers
|
|
90
|
+
const domMappings = await page.evaluate(() => {
|
|
91
|
+
const result: Record<string, string> = {};
|
|
92
|
+
const containers = document.querySelectorAll('.Qr7Oae');
|
|
93
|
+
|
|
94
|
+
containers.forEach((container: any) => {
|
|
95
|
+
const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
|
|
96
|
+
if (!headerEl) return;
|
|
97
|
+
|
|
98
|
+
let labelText = (headerEl.textContent || '').trim();
|
|
99
|
+
// Remove trailing required asterisks and clean up whitespace
|
|
100
|
+
labelText = labelText.replace(/\s*\*$/, '').trim();
|
|
101
|
+
|
|
102
|
+
let entryId: string | null = null;
|
|
103
|
+
|
|
104
|
+
// Strategy A: Find hidden or text inputs with name entry.XXXX
|
|
105
|
+
const inputs = container.querySelectorAll('input[name^="entry."], textarea[name^="entry."]');
|
|
106
|
+
for (const input of inputs) {
|
|
107
|
+
const nameAttr = input.getAttribute('name');
|
|
108
|
+
if (nameAttr && nameAttr.startsWith('entry.')) {
|
|
109
|
+
entryId = nameAttr.replace('entry.', '').replace('_sentinel', '');
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Strategy B: Parse from data-params if Strategy A fails
|
|
115
|
+
if (!entryId) {
|
|
116
|
+
let dataParams = container.getAttribute('data-params') || '';
|
|
117
|
+
if (!dataParams) {
|
|
118
|
+
const el = container.querySelector('[data-params]');
|
|
119
|
+
if (el) {
|
|
120
|
+
dataParams = el.getAttribute('data-params') || '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (dataParams) {
|
|
124
|
+
const match = dataParams.match(/\[\[(\d+)/);
|
|
125
|
+
if (match && match[1]) {
|
|
126
|
+
entryId = match[1];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (labelText && entryId) {
|
|
132
|
+
result[labelText] = entryId;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 3. Map mappingSchema properties to resolved entry IDs
|
|
140
|
+
const resolvedCache: Record<string, string> = {};
|
|
141
|
+
for (const [propName, fieldMapping] of Object.entries(this.mappingSchema)) {
|
|
142
|
+
// Try finding a match in the scraped titles
|
|
143
|
+
let matchedEntryId: string | undefined = undefined;
|
|
144
|
+
|
|
145
|
+
// Try exact match first
|
|
146
|
+
matchedEntryId = domMappings[fieldMapping.label];
|
|
147
|
+
|
|
148
|
+
// Try substring match as fallback
|
|
149
|
+
if (!matchedEntryId) {
|
|
150
|
+
const lowerLabel = fieldMapping.label.toLowerCase();
|
|
151
|
+
const scrapedTitle = Object.keys(domMappings).find(
|
|
152
|
+
(t) => t.toLowerCase().includes(lowerLabel) || lowerLabel.includes(t.toLowerCase())
|
|
153
|
+
);
|
|
154
|
+
if (scrapedTitle) {
|
|
155
|
+
matchedEntryId = domMappings[scrapedTitle];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (matchedEntryId) {
|
|
160
|
+
resolvedCache[propName] = matchedEntryId;
|
|
161
|
+
} else {
|
|
162
|
+
console.warn(`[GoogleFormSubmitter] Warning: Could not resolve entryId for field "${propName}" (label: "${fieldMapping.label}")`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Save to cache file
|
|
167
|
+
await fs.mkdir(path.dirname(this.cacheFilePath), { recursive: true });
|
|
168
|
+
await fs.writeFile(this.cacheFilePath, JSON.stringify(resolvedCache, null, 2));
|
|
169
|
+
console.log(`[GoogleFormSubmitter] Saved resolved entry IDs cache to: ${this.cacheFilePath}`);
|
|
170
|
+
|
|
171
|
+
this.entryIdCache = resolvedCache;
|
|
172
|
+
return resolvedCache;
|
|
173
|
+
} finally {
|
|
174
|
+
await context.close();
|
|
175
|
+
await browser.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validates input data and submits the form via direct HTTP POST.
|
|
181
|
+
*/
|
|
182
|
+
public async submit(data: unknown): Promise<SubmissionResult> {
|
|
183
|
+
// 1. Validate data against JSON Schema
|
|
184
|
+
const isValid = this.validateFn(data);
|
|
185
|
+
if (!isValid) {
|
|
186
|
+
const errorMsg = this.validateFn.errors
|
|
187
|
+
?.map((err) => `${err.instancePath} ${err.message}`)
|
|
188
|
+
.join(', ');
|
|
189
|
+
throw new Error(`Validation failed: ${errorMsg}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const typedData = data as Record<string, any>;
|
|
193
|
+
|
|
194
|
+
// 2. Get entryId mapping cache (resolves lazily if needed)
|
|
195
|
+
const entryIds = await this.getOrResolveEntryIds();
|
|
196
|
+
|
|
197
|
+
const fieldsMap: Record<string, string[]> = {};
|
|
198
|
+
const fieldsSubmitted: Record<string, string> = {};
|
|
199
|
+
|
|
200
|
+
for (const [propName, value] of Object.entries(typedData)) {
|
|
201
|
+
const entryId = entryIds[propName];
|
|
202
|
+
if (!entryId) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fieldMapping = this.mappingSchema[propName];
|
|
207
|
+
if (!fieldMapping) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!fieldsMap[entryId]) {
|
|
212
|
+
fieldsMap[entryId] = [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const currentList = fieldsMap[entryId];
|
|
216
|
+
|
|
217
|
+
if (fieldMapping.type === 'file') {
|
|
218
|
+
if (value && typeof value === 'object' && 'fileId' in value) {
|
|
219
|
+
const filePayload = [[[value.fileId, value.filename, value.mimeType]]];
|
|
220
|
+
const serialized = JSON.stringify(filePayload);
|
|
221
|
+
currentList?.push(serialized);
|
|
222
|
+
fieldsSubmitted[fieldMapping.label] = serialized;
|
|
223
|
+
}
|
|
224
|
+
} else if (fieldMapping.type === 'choice' && fieldMapping.allowOther && value) {
|
|
225
|
+
const isPredefined = fieldMapping.options?.choices?.includes(value);
|
|
226
|
+
if (isPredefined === false) {
|
|
227
|
+
currentList?.push('__other_option__');
|
|
228
|
+
|
|
229
|
+
const otherResponseKey = `${entryId}.other_option_response`;
|
|
230
|
+
let otherResponseList = fieldsMap[otherResponseKey];
|
|
231
|
+
if (!otherResponseList) {
|
|
232
|
+
otherResponseList = [];
|
|
233
|
+
fieldsMap[otherResponseKey] = otherResponseList;
|
|
234
|
+
}
|
|
235
|
+
otherResponseList.push(value);
|
|
236
|
+
|
|
237
|
+
fieldsSubmitted[fieldMapping.label] = `__other_option__ (${value})`;
|
|
238
|
+
} else {
|
|
239
|
+
currentList?.push(value);
|
|
240
|
+
fieldsSubmitted[fieldMapping.label] = value;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
currentList?.push(String(value));
|
|
244
|
+
fieldsSubmitted[fieldMapping.label] = String(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 4. Send direct HTTP POST inside the browser context to preserve cookies and hidden fields
|
|
249
|
+
let browser;
|
|
250
|
+
let context;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
browser = await chromium.connectOverCDP(this.cdpUrl, { timeout: 5000 });
|
|
254
|
+
context = await browser.newContext();
|
|
255
|
+
} catch {
|
|
256
|
+
browser = await chromium.launch({ headless: true });
|
|
257
|
+
context = await browser.newContext();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this.cookies && this.cookies.length > 0) {
|
|
261
|
+
await context.addCookies(this.cookies);
|
|
262
|
+
}
|
|
263
|
+
const page = await context.newPage();
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await page.goto(this.formUrl);
|
|
267
|
+
await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
|
|
268
|
+
|
|
269
|
+
console.log(`[GoogleFormSubmitter] Sending HTTP POST request in page context...`);
|
|
270
|
+
const responseInfo = await page.evaluate(async ({ fieldsMap }) => {
|
|
271
|
+
const g = globalThis as any;
|
|
272
|
+
const form = g.document.querySelector('form');
|
|
273
|
+
if (!form) throw new Error('Could not find form element on page');
|
|
274
|
+
|
|
275
|
+
const searchParams = new URLSearchParams();
|
|
276
|
+
const formData = new g.FormData(form);
|
|
277
|
+
|
|
278
|
+
for (const [key, val] of formData.entries()) {
|
|
279
|
+
if (!key.startsWith('entry.')) {
|
|
280
|
+
searchParams.append(key, String(val));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const [entryId, values] of Object.entries(fieldsMap)) {
|
|
285
|
+
for (const val of values as string[]) {
|
|
286
|
+
searchParams.append(`entry.${entryId}`, val);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const actionUrl = form.action || g.location.href.replace('/viewform', '/formResponse');
|
|
291
|
+
|
|
292
|
+
const res = await g.fetch(actionUrl, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
body: searchParams,
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
status: res.status,
|
|
302
|
+
text: await res.text()
|
|
303
|
+
};
|
|
304
|
+
}, { fieldsMap });
|
|
305
|
+
|
|
306
|
+
const isSuccess = responseInfo.status === 200 || responseInfo.text.includes('v-confirmation-msg') || responseInfo.text.toLowerCase().includes('confirmation');
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
success: isSuccess,
|
|
310
|
+
statusCode: responseInfo.status,
|
|
311
|
+
message: isSuccess ? 'Form submitted successfully!' : 'Google Form rejected submission.',
|
|
312
|
+
fieldsSubmitted,
|
|
313
|
+
};
|
|
314
|
+
} finally {
|
|
315
|
+
await context.close();
|
|
316
|
+
await browser.close();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Google Form Submitter
|
|
2
|
+
|
|
3
|
+
Пакет для строго типизированной, валидируемой и кэшируемой отправки данных во внешние (в том числе сторонние) Google Формы с использованием Lightpanda (CDP-соединение) или стандартного Chromium.
|
|
4
|
+
|
|
5
|
+
## 🚀 Возможности
|
|
6
|
+
|
|
7
|
+
1. **Строгая валидация (JSON Schema)**: Входные данные проверяются через стандартную JSON схему с использованием библиотеки `Ajv` перед любой сетевой отправкой.
|
|
8
|
+
2. **Декларативное описание полей**: Позволяет сопоставлять программные свойства (например, `fullName`, `exitDate`) с русскими заголовками вопросов в Google Форме.
|
|
9
|
+
3. **Ленивый парсинг DOM (Lazy Loading)**: При первой отправке модуль автоматически открывает форму в безголовом браузере, сканирует структуру вопросов, находит соответствующие им бэкенд `entryId` и сохраняет карту соответствий в локальный кэш-файл.
|
|
10
|
+
4. **Сверхбыстрые повторные отправки**: После наполнения кэша последующие отправки выполняются мгновенно через прямой HTTP POST-запрос, эмулируя отправку формы без запуска графического браузера.
|
|
11
|
+
5. **Авторизация (Куки)**: Поддерживает инжекцию файлов куки для прохождения авторизации в формах, требующих аккаунт Google (например, если в форме есть поле загрузки файла).
|
|
12
|
+
6. **Поддержка опции «Другое»**: Автоматически распознает кастомные текстовые ответы для радио-кнопок/чекбоксов с выбором «Другое» и правильно их кодирует.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 🛠️ Установка и Запуск
|
|
17
|
+
|
|
18
|
+
Перейдите в каталог `googleformsubmitter` и установите зависимости:
|
|
19
|
+
```bash
|
|
20
|
+
cd googleformsubmitter
|
|
21
|
+
bun install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Доступные скрипты:
|
|
25
|
+
* **Запуск тестов**:
|
|
26
|
+
```bash
|
|
27
|
+
bun run test
|
|
28
|
+
```
|
|
29
|
+
* **Проверка типов (tsgo)**:
|
|
30
|
+
```bash
|
|
31
|
+
bun run validate
|
|
32
|
+
```
|
|
33
|
+
* **Линтинг (oxlint)**:
|
|
34
|
+
```bash
|
|
35
|
+
bun run lint
|
|
36
|
+
```
|
|
37
|
+
* **Создание тестовой Google Формы**:
|
|
38
|
+
```bash
|
|
39
|
+
bun run create-form
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📋 Описание Схемы данных
|
|
45
|
+
|
|
46
|
+
Отправка настраивается с помощью двух схем:
|
|
47
|
+
|
|
48
|
+
1. **JSON Schema** — описывает типы данных, ограничения, обязательность и формат (например, регулярные выражения для дат).
|
|
49
|
+
2. **Mapping Schema** — описывает связь между ключом в JSON схеме и человекочитаемым заголовком вопроса в Google Форме.
|
|
50
|
+
|
|
51
|
+
### Пример декларативной конфигурации:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { GoogleFormSubmitter } from './GoogleFormSubmitter';
|
|
55
|
+
|
|
56
|
+
// 1. Описание типов данных формы (JSON Schema)
|
|
57
|
+
const jsonSchema = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
fullName: { type: 'string' },
|
|
61
|
+
birthDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
|
62
|
+
inStaff: { type: 'string' },
|
|
63
|
+
cvFile: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
fileId: { type: 'string' },
|
|
67
|
+
filename: { type: 'string' },
|
|
68
|
+
mimeType: { type: 'string' }
|
|
69
|
+
},
|
|
70
|
+
required: ['fileId', 'filename', 'mimeType']
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
required: ['fullName', 'birthDate', 'inStaff', 'cvFile']
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 2. Декларативный маппинг полей на заголовки вопросов в Google Форме
|
|
77
|
+
const mappingSchema = {
|
|
78
|
+
fullName: { label: 'ФИО (полностью)', type: 'text' },
|
|
79
|
+
birthDate: { label: 'Дата рождения', type: 'date' },
|
|
80
|
+
inStaff: { label: 'Находится ли специалист в штате?', type: 'choice', options: { choices: ['Да'], allowOther: true } },
|
|
81
|
+
cvFile: { label: 'CV (файл)', type: 'file' }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// 3. Инициализация сабмиттера
|
|
85
|
+
const submitter = new GoogleFormSubmitter({
|
|
86
|
+
formUrl: 'https://docs.google.com/forms/d/e/.../viewform',
|
|
87
|
+
jsonSchema,
|
|
88
|
+
mappingSchema,
|
|
89
|
+
cdpUrl: 'ws://127.0.0.1:9222/', // CDP-сервер Lightpanda
|
|
90
|
+
cookies: loadedCookies // Сессионные куки (необязательно)
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 4. Отправка данных
|
|
94
|
+
const result = await submitter.submit({
|
|
95
|
+
fullName: 'Иванов Иван',
|
|
96
|
+
birthDate: '1990-01-01',
|
|
97
|
+
inStaff: 'С рынка, на предоффере', // Будет передано в "Другое"
|
|
98
|
+
cvFile: {
|
|
99
|
+
fileId: '1BRVwwPlohb_4g7DUZ3HTz...',
|
|
100
|
+
filename: 'resume.docx',
|
|
101
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 📂 Структура каталогов проекта
|
|
109
|
+
|
|
110
|
+
* `GoogleFormSubmitter.ts` — основной класс управления, парсинга DOM и сетевых запросов.
|
|
111
|
+
* `create-form.ts` — скрипт автоматического создания тестовой Google-формы со всеми необходимыми типами полей (включая текстовые, списки, даты, переключатели) через официальный Google Forms API.
|
|
112
|
+
* `test.ts` — интеграционный тест, эмулирующий первую отправку (с динамическим парсингом) и повторную отправку (из кэша).
|
|
113
|
+
* `types.ts` — TypeScript-интерфейсы и типы данных.
|
|
114
|
+
* `.data/` — каталог для хранения временных кэшей сопоставлений entryId (`form-cache-*.json`).
|
|
115
|
+
* `.env.example` — шаблон конфигурационного файла.
|