@volter/twin-tiktok 0.1.36 → 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/README.md +25 -308
- package/business/spec/SOURCE.md +17 -0
- package/business/spec/openapi.json +1045 -0
- package/business/spec/sources.json +84 -0
- package/business/src/fetch.ts +14 -0
- package/business/src/generated/surface.gen.json +1 -0
- package/business/src/generated/ui.gen.json +1 -0
- package/business/src/index.ts +3 -0
- package/business/src/manifest.ts +38 -0
- package/business/src/semantics/index.ts +16 -0
- package/business/src/semantics/open_api.ts +142 -0
- package/business/src/semantics/shared.ts +119 -0
- package/business/src/semantics/states.ts +3 -0
- package/dist/business/spec/SOURCE.md +17 -0
- package/dist/business/spec/openapi.json +1045 -0
- package/dist/business/spec/sources.json +84 -0
- package/dist/business/src/fetch.d.ts +2 -0
- package/dist/business/src/fetch.js +13 -0
- package/dist/business/src/fetch.ts +14 -0
- package/dist/business/src/generated/surface.gen.json +1 -0
- package/dist/business/src/generated/ui.gen.json +1 -0
- package/dist/business/src/index.d.ts +2 -0
- package/dist/business/src/index.js +3 -0
- package/dist/business/src/index.ts +3 -0
- package/dist/business/src/manifest.d.ts +2 -0
- package/dist/business/src/manifest.js +33 -0
- package/dist/business/src/manifest.ts +38 -0
- package/dist/business/src/semantics/index.d.ts +2 -0
- package/dist/business/src/semantics/index.js +12 -0
- package/dist/business/src/semantics/index.ts +16 -0
- package/dist/business/src/semantics/open_api.d.ts +10 -0
- package/dist/business/src/semantics/open_api.js +162 -0
- package/dist/business/src/semantics/open_api.ts +142 -0
- package/dist/business/src/semantics/shared.d.ts +31 -0
- package/dist/business/src/semantics/shared.js +109 -0
- package/dist/business/src/semantics/shared.ts +119 -0
- package/dist/business/src/semantics/states.d.ts +8 -0
- package/dist/business/src/semantics/states.js +3 -0
- package/dist/business/src/semantics/states.ts +3 -0
- package/dist/generated/pack-facts.json +55 -0
- package/dist/journeys/coverage.json +220 -0
- package/dist/journeys/customer-life.json +2950 -0
- package/dist/journeys/customer-life.outline.md +59 -0
- package/dist/journeys/decisions.json +161 -0
- package/dist/journeys/demand.json +142 -0
- package/dist/journeys/score.json +294 -0
- package/dist/journeys/unreachable.json +13 -0
- package/dist/open/spec/SOURCE.md +20 -0
- package/dist/open/spec/openapi.json +1417 -0
- package/dist/open/spec/sources.json +124 -0
- package/dist/open/src/engine/mp4.d.ts +6 -0
- package/dist/open/src/engine/mp4.js +55 -0
- package/dist/open/src/engine/mp4.ts +48 -0
- package/dist/open/src/fetch.d.ts +2 -0
- package/dist/open/src/fetch.js +17 -0
- package/dist/open/src/fetch.ts +18 -0
- package/dist/open/src/generated/surface.gen.json +1 -0
- package/dist/open/src/generated/ui.gen.json +1 -0
- package/dist/open/src/index.d.ts +2 -0
- package/dist/open/src/index.js +3 -0
- package/dist/open/src/index.ts +3 -0
- package/dist/open/src/manifest.d.ts +2 -0
- package/dist/open/src/manifest.js +53 -0
- package/dist/open/src/manifest.ts +59 -0
- package/dist/open/src/screens/authorize.d.ts +2 -0
- package/dist/open/src/screens/authorize.js +53 -0
- package/dist/open/src/screens/authorize.ts +53 -0
- package/dist/open/src/semantics/doors.d.ts +10 -0
- package/dist/open/src/semantics/doors.js +118 -0
- package/dist/open/src/semantics/doors.ts +121 -0
- package/dist/open/src/semantics/index.d.ts +2 -0
- package/dist/open/src/semantics/index.js +16 -0
- package/dist/open/src/semantics/index.ts +20 -0
- package/dist/open/src/semantics/oauth.d.ts +2 -0
- package/dist/open/src/semantics/oauth.js +30 -0
- package/dist/open/src/semantics/oauth.ts +28 -0
- package/dist/open/src/semantics/post.d.ts +6 -0
- package/dist/open/src/semantics/post.js +66 -0
- package/dist/open/src/semantics/post.ts +60 -0
- package/dist/open/src/semantics/shared.d.ts +51 -0
- package/dist/open/src/semantics/shared.js +196 -0
- package/dist/open/src/semantics/shared.ts +197 -0
- package/dist/open/src/semantics/states.d.ts +20 -0
- package/dist/open/src/semantics/states.js +15 -0
- package/dist/open/src/semantics/states.ts +23 -0
- package/dist/open/src/semantics/user.d.ts +2 -0
- package/dist/open/src/semantics/user.js +24 -0
- package/dist/open/src/semantics/user.ts +24 -0
- package/dist/open/src/semantics/video.d.ts +4 -0
- package/dist/open/src/semantics/video.js +112 -0
- package/dist/open/src/semantics/video.ts +97 -0
- package/dist/src/cli.js +11 -34
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +9 -0
- package/dist/src/index.d.ts +5 -22
- package/dist/src/index.js +13 -167
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +28 -0
- package/dist/src/semantics/shared.d.ts +49 -0
- package/dist/src/semantics/shared.js +61 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +55 -0
- package/journeys/coverage.json +220 -0
- package/journeys/customer-life.json +2950 -0
- package/journeys/customer-life.outline.md +59 -0
- package/journeys/decisions.json +161 -0
- package/journeys/demand.json +142 -0
- package/journeys/score.json +294 -0
- package/journeys/unreachable.json +13 -0
- package/open/spec/SOURCE.md +20 -0
- package/open/spec/openapi.json +1417 -0
- package/open/spec/sources.json +124 -0
- package/open/src/engine/mp4.ts +48 -0
- package/open/src/fetch.ts +18 -0
- package/open/src/generated/surface.gen.json +1 -0
- package/open/src/generated/ui.gen.json +1 -0
- package/open/src/index.ts +3 -0
- package/open/src/manifest.ts +59 -0
- package/open/src/screens/authorize.ts +53 -0
- package/open/src/semantics/doors.ts +121 -0
- package/open/src/semantics/index.ts +20 -0
- package/open/src/semantics/oauth.ts +28 -0
- package/open/src/semantics/post.ts +60 -0
- package/open/src/semantics/shared.ts +197 -0
- package/open/src/semantics/states.ts +23 -0
- package/open/src/semantics/user.ts +24 -0
- package/open/src/semantics/video.ts +97 -0
- package/package.json +13 -41
- package/src/cli.ts +11 -32
- package/src/fetch.ts +10 -0
- package/src/index.ts +15 -270
- package/src/manifest.ts +33 -0
- package/src/semantics/shared.ts +78 -0
- package/src/server.ts +8 -0
- package/client/tiktok-consent.tsx +0 -154
- package/client/tiktok-mirror.css +0 -137
- package/client/tiktok-mirror.tsx +0 -497
- package/dist/client/tiktok-consent.bundle.js +0 -18
- package/dist/client/tiktok-consent.d.ts +0 -47
- package/dist/client/tiktok-consent.js +0 -20
- package/dist/client/tiktok-consent.tsx +0 -154
- package/dist/client/tiktok-mirror.bundle.js +0 -484
- package/dist/client/tiktok-mirror.css +0 -137
- package/dist/client/tiktok-mirror.d.ts +0 -42
- package/dist/client/tiktok-mirror.js +0 -325
- package/dist/client/tiktok-mirror.tsx +0 -497
- package/dist/src/tiktok-blobs.d.ts +0 -66
- package/dist/src/tiktok-blobs.js +0 -161
- package/dist/src/tiktok-budget.d.ts +0 -56
- package/dist/src/tiktok-budget.js +0 -136
- package/dist/src/tiktok-capabilities.d.ts +0 -7
- package/dist/src/tiktok-capabilities.js +0 -1855
- package/dist/src/tiktok-conformance.d.ts +0 -11
- package/dist/src/tiktok-conformance.js +0 -498
- package/dist/src/tiktok-connector.d.ts +0 -158
- package/dist/src/tiktok-connector.js +0 -600
- package/dist/src/tiktok-consent-ui.d.ts +0 -19
- package/dist/src/tiktok-consent-ui.js +0 -127
- package/dist/src/tiktok-errors.d.ts +0 -78
- package/dist/src/tiktok-errors.js +0 -175
- package/dist/src/tiktok-ids.d.ts +0 -16
- package/dist/src/tiktok-ids.js +0 -48
- package/dist/src/tiktok-media.d.ts +0 -7
- package/dist/src/tiktok-media.js +0 -86
- package/dist/src/tiktok-mirror-ui.d.ts +0 -61
- package/dist/src/tiktok-mirror-ui.js +0 -187
- package/dist/src/tiktok-pkce.d.ts +0 -25
- package/dist/src/tiktok-pkce.js +0 -56
- package/dist/src/tiktok-posting.d.ts +0 -100
- package/dist/src/tiktok-posting.js +0 -599
- package/dist/src/tiktok-sample-mp4.d.ts +0 -10
- package/dist/src/tiktok-sample-mp4.js +0 -55
- package/dist/src/tiktok-scopes.d.ts +0 -29
- package/dist/src/tiktok-scopes.js +0 -106
- package/dist/src/tiktok-server.d.ts +0 -28
- package/dist/src/tiktok-server.js +0 -99
- package/dist/src/tiktok-store.d.ts +0 -164
- package/dist/src/tiktok-store.js +0 -451
- package/dist/src/tiktok-twin.d.ts +0 -70
- package/dist/src/tiktok-twin.js +0 -1197
- package/dist/src/tiktok-user.d.ts +0 -28
- package/dist/src/tiktok-user.js +0 -174
- package/src/tiktok-blobs.ts +0 -217
- package/src/tiktok-budget.ts +0 -163
- package/src/tiktok-capabilities.ts +0 -2022
- package/src/tiktok-conformance.ts +0 -526
- package/src/tiktok-connector.ts +0 -637
- package/src/tiktok-consent-ui.ts +0 -146
- package/src/tiktok-errors.ts +0 -197
- package/src/tiktok-ids.ts +0 -51
- package/src/tiktok-journey.uitest.ts +0 -305
- package/src/tiktok-media.ts +0 -89
- package/src/tiktok-mirror-ui.ts +0 -193
- package/src/tiktok-pkce.ts +0 -61
- package/src/tiktok-posting.ts +0 -617
- package/src/tiktok-sample-mp4.ts +0 -54
- package/src/tiktok-scopes.ts +0 -122
- package/src/tiktok-server.ts +0 -110
- package/src/tiktok-store.ts +0 -543
- package/src/tiktok-twin.ts +0 -1361
- package/src/tiktok-user.ts +0 -137
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from '../../../src/semantics/shared.js';
|
|
3
|
+
export * from '../../../src/semantics/shared.js';
|
|
4
|
+
export type { HandlerContext };
|
|
5
|
+
export declare const ok: (ctx: Pick<HandlerContext, "crypto" | "occurredAt">, data: unknown) => Response;
|
|
6
|
+
/** A refusal: its code and message, at HTTP 200. */
|
|
7
|
+
export declare const refuse: (ctx: Pick<HandlerContext, "crypto" | "occurredAt">, code: number, message: string) => Response;
|
|
8
|
+
export declare const PARAM = 40002;
|
|
9
|
+
export declare const NO_PERMISSION = 40001;
|
|
10
|
+
/** Who a request is: the business access token its Access-Token header bears, live, with its app and its person. */
|
|
11
|
+
export type Caller = {
|
|
12
|
+
token: Row;
|
|
13
|
+
app: Row;
|
|
14
|
+
person: Row;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
openId: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function authorized(ctx: HandlerContext, scope?: string): Caller | Response;
|
|
19
|
+
/** A JSON-array query parameter (`fields`, as Postiz sends it), or the refusal. */
|
|
20
|
+
export declare function jsonArray(ctx: HandlerContext, name: string): string[] | undefined | Response;
|
|
21
|
+
export declare function appOf(ctx: HandlerContext): Promise<Row | Response>;
|
|
22
|
+
export declare function issue(ctx: HandlerContext, app: Row, person: Row, scope: string): Promise<Response>;
|
|
23
|
+
export declare const DEFAULT: string[];
|
|
24
|
+
export declare const profileFields: string[];
|
|
25
|
+
export declare const postsFields: string[];
|
|
26
|
+
export declare const publishObj: (v: unknown) => Row;
|
|
27
|
+
/** Pull each URL through the World; the bytes pulled, or undefined when one fails. */
|
|
28
|
+
export declare function pullAll(ctx: HandlerContext, urls: string[]): Promise<number | undefined>;
|
|
29
|
+
/** The video a completed pull makes (the Video Object, as the open lane makes them), public; its id. */
|
|
30
|
+
export declare function madeVideo(ctx: HandlerContext, publishId: string, post: Row, person: Row): Promise<string>;
|
|
31
|
+
export declare function publish(ctx: HandlerContext, media: 'VIDEO' | 'PHOTO', urls: string[], post: Row, draft: boolean, person: Row): Promise<Response>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ACCESS, ACCESS_TTL, APP, PERSON, PUBLISH, REFRESH, REFRESH_TTL, VIDEO, bodyOf, bySha, clientSecret, credential, mediaUrl, next, openIdOf, seconds, videoMoment } from "../../../src/semantics/shared.js";
|
|
2
|
+
export * from "../../../src/semantics/shared.js";
|
|
3
|
+
/** A request's id, as the API answers it. */
|
|
4
|
+
const requestId = (ctx, what) => `${ctx.occurredAt.replace(/\D/g, '').slice(0, 14)}${ctx.crypto.digest('sha256', `${ctx.occurredAt}:business:${what}`, 'hex').slice(0, 18).toUpperCase()}`;
|
|
5
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "Successful"
|
|
6
|
+
export const ok = (ctx, data) => Response.json({ code: 0, message: 'OK', request_id: requestId(ctx, JSON.stringify(data)), data });
|
|
7
|
+
/** A refusal: its code and message, at HTTP 200. */
|
|
8
|
+
export const refuse = (ctx, code, message) => Response.json({ code, message, request_id: requestId(ctx, `${code}:${message}`), data: {} });
|
|
9
|
+
// the codes the lane answers (Appendix - Return codes)
|
|
10
|
+
export const PARAM = 40002;
|
|
11
|
+
export const NO_PERMISSION = 40001;
|
|
12
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "Invalid or incorrect access token."
|
|
13
|
+
export function authorized(ctx, scope) {
|
|
14
|
+
const value = ctx.call.request.headers.get('access-token') ?? '';
|
|
15
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "The access token is empty."
|
|
16
|
+
if (!value)
|
|
17
|
+
return refuse(ctx, 40104, 'The access token is empty.');
|
|
18
|
+
const token = bySha(ctx, ACCESS, value);
|
|
19
|
+
if (!token || token.kind !== 'business' || token.revoked === true)
|
|
20
|
+
return refuse(ctx, 40105, 'Invalid or incorrect access token.');
|
|
21
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "The access token has expired."
|
|
22
|
+
if (Number(token.expires_at) <= seconds(ctx.occurredAt))
|
|
23
|
+
return refuse(ctx, 40102, 'The access token has expired.');
|
|
24
|
+
const app = ctx.row(APP, String(token.app));
|
|
25
|
+
const person = ctx.row(PERSON, String(token.person));
|
|
26
|
+
const openId = openIdOf(ctx, app, person);
|
|
27
|
+
const businessId = String(ctx.params.business_id ?? '');
|
|
28
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "No permission to perform the related operation."
|
|
29
|
+
if (businessId !== openId)
|
|
30
|
+
return refuse(ctx, NO_PERMISSION, 'No permission to operate this TikTok account: business_id is not the access token\'s.');
|
|
31
|
+
const scopes = String(token.scope).split(',');
|
|
32
|
+
if (scope && !scopes.includes(scope))
|
|
33
|
+
return refuse(ctx, NO_PERMISSION, `No permission: the TikTok account did not grant ${scope}.`);
|
|
34
|
+
return { token, app, person, scopes, openId };
|
|
35
|
+
}
|
|
36
|
+
/** A JSON-array query parameter (`fields`, as Postiz sends it), or the refusal. */
|
|
37
|
+
export function jsonArray(ctx, name) {
|
|
38
|
+
const raw = new URL(ctx.call.request.url).searchParams.get(name);
|
|
39
|
+
if (raw === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const v = JSON.parse(raw);
|
|
43
|
+
if (Array.isArray(v) && v.every((x) => typeof x === 'string'))
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
catch { /* the refusal below */ }
|
|
47
|
+
return refuse(ctx, PARAM, `${name} must be a JSON array of strings.`);
|
|
48
|
+
}
|
|
49
|
+
export async function appOf(ctx) {
|
|
50
|
+
const b = bodyOf(ctx);
|
|
51
|
+
const app = ctx.rowsRaw(APP).find((a) => a.deleted !== true && a.kind === 'business' && a.client_key === b.client_id);
|
|
52
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "App ID or secret is incorrect."
|
|
53
|
+
if (!app || b.client_secret !== await clientSecret(ctx, app))
|
|
54
|
+
return refuse(ctx, 40131, 'App ID or secret is incorrect.');
|
|
55
|
+
return app;
|
|
56
|
+
}
|
|
57
|
+
export async function issue(ctx, app, person, scope) {
|
|
58
|
+
const now = seconds(ctx.occurredAt);
|
|
59
|
+
const access = await credential(ctx, ACCESS, 'act', { kind: 'business', app: app.id, person: person.id, scope, expires_at: now + ACCESS_TTL, revoked: false });
|
|
60
|
+
const refresh = await credential(ctx, REFRESH, 'rft', { kind: 'business', app: app.id, person: person.id, scope, access: access.id, expires_at: now + REFRESH_TTL, used: false });
|
|
61
|
+
return ok(ctx, { access_token: access.value, token_type: 'Bearer', scope, expires_in: ACCESS_TTL, refresh_token: refresh.value, refresh_token_expires_in: REFRESH_TTL, open_id: openIdOf(ctx, app, person) });
|
|
62
|
+
}
|
|
63
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1762228399168514&language=ENGLISH "Requested fields. If not set, returns the"
|
|
64
|
+
export const DEFAULT = ['display_name', 'profile_image'];
|
|
65
|
+
export const profileFields = ['display_name', 'profile_image', 'username', 'profile_deep_link', 'bio_description', 'is_verified', 'followers_count', 'following_count', 'total_likes', 'videos_count'];
|
|
66
|
+
export const postsFields = ['item_id', 'create_time', 'thumbnail_url', 'share_url', 'embed_url', 'caption', 'video_duration', 'likes', 'comments', 'shares', 'video_views'];
|
|
67
|
+
export const publishObj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
68
|
+
/** Pull each URL through the World; the bytes pulled, or undefined when one fails. */
|
|
69
|
+
export async function pullAll(ctx, urls) {
|
|
70
|
+
let n = 0;
|
|
71
|
+
for (const url of urls) {
|
|
72
|
+
try {
|
|
73
|
+
const r = await ctx.vendorFetch(url, { method: 'GET' });
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
return undefined;
|
|
76
|
+
n += (await r.arrayBuffer()).byteLength;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
/** The video a completed pull makes (the Video Object, as the open lane makes them), public; its id. */
|
|
85
|
+
export async function madeVideo(ctx, publishId, post, person) {
|
|
86
|
+
const videoId = String(7_400_000_000_000_000_000 + (await next(ctx, 'video')) * 1024);
|
|
87
|
+
const caption = typeof post.caption === 'string' ? post.caption : '';
|
|
88
|
+
await ctx.write(VIDEO, videoId, {
|
|
89
|
+
id: videoId, create_time: seconds(ctx.occurredAt), _moment: videoMoment(ctx, String(person.id)), cover_image_url: mediaUrl(ctx, `cover-${videoId}`), share_url: `https://www.tiktok.com/@${String(person.username)}/video/${videoId}`,
|
|
90
|
+
video_description: caption.slice(0, 150), duration: 0, height: 0, width: 0, title: caption.slice(0, 150),
|
|
91
|
+
embed_html: `<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@${String(person.username)}/video/${videoId}" data-video-id="${videoId}"></blockquote>`,
|
|
92
|
+
embed_link: `https://www.tiktok.com/player/v1/${videoId}`, like_count: 0, comment_count: 0, share_count: 0, view_count: 0, is_aigc: post.is_ai_generated === true,
|
|
93
|
+
_person: person.id, _public: true, _publish: publishId,
|
|
94
|
+
}, 'video.publish');
|
|
95
|
+
return videoId;
|
|
96
|
+
}
|
|
97
|
+
export async function publish(ctx, media, urls, post, draft, person) {
|
|
98
|
+
const n = await next(ctx, 'publish');
|
|
99
|
+
const id = `${media === 'VIDEO' ? 'v' : 'p'}_pub_url~v2.${n}`;
|
|
100
|
+
await ctx.write(PUBLISH, id, { status: 'PROCESSING_DOWNLOAD', post_ids: [], _person: person.id, _media: media, _urls: urls, _post_info: post, _draft: draft, _lane: 'business' }, media === 'VIDEO' ? 'businessVideoPublish' : 'businessPhotoPublish');
|
|
101
|
+
const pulled = await pullAll(ctx, urls);
|
|
102
|
+
const to = pulled === undefined ? 'FAILED' : draft ? 'SEND_TO_USER_INBOX' : 'PUBLISH_COMPLETE';
|
|
103
|
+
const refused = ctx.legal(PUBLISH, 'status', 'publish.pull', 'PROCESSING_DOWNLOAD', to, id, 'vendor');
|
|
104
|
+
if (refused)
|
|
105
|
+
throw new Error(refused.message);
|
|
106
|
+
const postIds = to === 'PUBLISH_COMPLETE' && media === 'VIDEO' ? [await madeVideo(ctx, id, post, person)] : [];
|
|
107
|
+
await ctx.write(PUBLISH, id, { status: to, post_ids: postIds, ...(to === 'FAILED' ? { reason: media === 'VIDEO' ? 'video_pull_failed' : 'photo_pull_failed' } : {}) }, 'publish.pull');
|
|
108
|
+
return ok(ctx, { share_id: id });
|
|
109
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What every family of the business lane shares beyond the vendor's (../../../src/semantics/shared.ts): the API for
|
|
2
|
+
// Business's envelope (`{code, message, request_id, data}`, a refusal a non-zero code at HTTP 200) and who a request is
|
|
3
|
+
// (its Access-Token, a business app's, and the business_id it names).
|
|
4
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
5
|
+
import { ACCESS, ACCESS_TTL, APP, CODE, PERSON, PUBLISH, REFRESH, REFRESH_TTL, VIDEO, bodyOf, bySha, clientSecret, credential, mediaUrl, next, openIdOf, privacyOptions, seconds, type Row, videoMoment, videoPage } from '../../../src/semantics/shared.ts';
|
|
6
|
+
|
|
7
|
+
export * from '../../../src/semantics/shared.ts';
|
|
8
|
+
export type { HandlerContext };
|
|
9
|
+
|
|
10
|
+
/** A request's id, as the API answers it. */
|
|
11
|
+
const requestId = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, what: string): string =>
|
|
12
|
+
`${ctx.occurredAt.replace(/\D/g, '').slice(0, 14)}${ctx.crypto.digest('sha256', `${ctx.occurredAt}:business:${what}`, 'hex').slice(0, 18).toUpperCase()}`;
|
|
13
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "Successful"
|
|
14
|
+
export const ok = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, data: unknown): Response =>
|
|
15
|
+
Response.json({ code: 0, message: 'OK', request_id: requestId(ctx, JSON.stringify(data)), data });
|
|
16
|
+
/** A refusal: its code and message, at HTTP 200. */
|
|
17
|
+
export const refuse = (ctx: Pick<HandlerContext, 'crypto' | 'occurredAt'>, code: number, message: string): Response =>
|
|
18
|
+
Response.json({ code, message, request_id: requestId(ctx, `${code}:${message}`), data: {} });
|
|
19
|
+
|
|
20
|
+
// the codes the lane answers (Appendix - Return codes)
|
|
21
|
+
export const PARAM = 40002;
|
|
22
|
+
export const NO_PERMISSION = 40001;
|
|
23
|
+
|
|
24
|
+
/** Who a request is: the business access token its Access-Token header bears, live, with its app and its person. */
|
|
25
|
+
export type Caller = { token: Row; app: Row; person: Row; scopes: string[]; openId: string };
|
|
26
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "Invalid or incorrect access token."
|
|
27
|
+
export function authorized(ctx: HandlerContext, scope?: string): Caller | Response {
|
|
28
|
+
const value = ctx.call.request.headers.get('access-token') ?? '';
|
|
29
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "The access token is empty."
|
|
30
|
+
if (!value) return refuse(ctx, 40104, 'The access token is empty.');
|
|
31
|
+
const token = bySha(ctx, ACCESS, value);
|
|
32
|
+
if (!token || token.kind !== 'business' || token.revoked === true) return refuse(ctx, 40105, 'Invalid or incorrect access token.');
|
|
33
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "The access token has expired."
|
|
34
|
+
if (Number(token.expires_at) <= seconds(ctx.occurredAt)) return refuse(ctx, 40102, 'The access token has expired.');
|
|
35
|
+
const app = ctx.row(APP, String(token.app))!;
|
|
36
|
+
const person = ctx.row(PERSON, String(token.person))!;
|
|
37
|
+
const openId = openIdOf(ctx, app, person);
|
|
38
|
+
const businessId = String(ctx.params.business_id ?? '');
|
|
39
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "No permission to perform the related operation."
|
|
40
|
+
if (businessId !== openId) return refuse(ctx, NO_PERMISSION, 'No permission to operate this TikTok account: business_id is not the access token\'s.');
|
|
41
|
+
const scopes = String(token.scope).split(',');
|
|
42
|
+
if (scope && !scopes.includes(scope)) return refuse(ctx, NO_PERMISSION, `No permission: the TikTok account did not grant ${scope}.`);
|
|
43
|
+
return { token, app, person, scopes, openId };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A JSON-array query parameter (`fields`, as Postiz sends it), or the refusal. */
|
|
47
|
+
export function jsonArray(ctx: HandlerContext, name: string): string[] | undefined | Response {
|
|
48
|
+
const raw = new URL(ctx.call.request.url).searchParams.get(name);
|
|
49
|
+
if (raw === null) return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const v = JSON.parse(raw) as unknown;
|
|
52
|
+
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v as string[];
|
|
53
|
+
} catch { /* the refusal below */ }
|
|
54
|
+
return refuse(ctx, PARAM, `${name} must be a JSON array of strings.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function appOf(ctx: HandlerContext): Promise<Row | Response> {
|
|
58
|
+
const b = bodyOf(ctx);
|
|
59
|
+
const app = ctx.rowsRaw(APP).find((a) => a.deleted !== true && a.kind === 'business' && a.client_key === b.client_id);
|
|
60
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1737172488964097&language=ENGLISH "App ID or secret is incorrect."
|
|
61
|
+
if (!app || b.client_secret !== await clientSecret(ctx, app)) return refuse(ctx, 40131, 'App ID or secret is incorrect.');
|
|
62
|
+
return app;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function issue(ctx: HandlerContext, app: Row, person: Row, scope: string): Promise<Response> {
|
|
66
|
+
const now = seconds(ctx.occurredAt);
|
|
67
|
+
const access = await credential(ctx, ACCESS, 'act', { kind: 'business', app: app.id, person: person.id, scope, expires_at: now + ACCESS_TTL, revoked: false });
|
|
68
|
+
const refresh = await credential(ctx, REFRESH, 'rft', { kind: 'business', app: app.id, person: person.id, scope, access: access.id, expires_at: now + REFRESH_TTL, used: false });
|
|
69
|
+
return ok(ctx, { access_token: access.value, token_type: 'Bearer', scope, expires_in: ACCESS_TTL, refresh_token: refresh.value, refresh_token_expires_in: REFRESH_TTL, open_id: openIdOf(ctx, app, person) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// source: https://business-api.tiktok.com/gateway/api/doc/client/node/get/v2/?identify_key=c0138ffadd90a955c1f0670a56fe348d1d40680b3c89461e09f78ed26785164b&doc_id=1762228399168514&language=ENGLISH "Requested fields. If not set, returns the"
|
|
73
|
+
export const DEFAULT = ['display_name', 'profile_image'];
|
|
74
|
+
|
|
75
|
+
export const profileFields = ['display_name', 'profile_image', 'username', 'profile_deep_link', 'bio_description', 'is_verified', 'followers_count', 'following_count', 'total_likes', 'videos_count'];
|
|
76
|
+
|
|
77
|
+
export const postsFields = ['item_id', 'create_time', 'thumbnail_url', 'share_url', 'embed_url', 'caption', 'video_duration', 'likes', 'comments', 'shares', 'video_views'];
|
|
78
|
+
|
|
79
|
+
export const publishObj = (v: unknown): Row => (v && typeof v === 'object' && !Array.isArray(v) ? v as Row : {});
|
|
80
|
+
|
|
81
|
+
/** Pull each URL through the World; the bytes pulled, or undefined when one fails. */
|
|
82
|
+
export async function pullAll(ctx: HandlerContext, urls: string[]): Promise<number | undefined> {
|
|
83
|
+
let n = 0;
|
|
84
|
+
for (const url of urls) {
|
|
85
|
+
try {
|
|
86
|
+
const r = await ctx.vendorFetch(url, { method: 'GET' });
|
|
87
|
+
if (!r.ok) return undefined;
|
|
88
|
+
n += (await r.arrayBuffer()).byteLength;
|
|
89
|
+
} catch { return undefined; }
|
|
90
|
+
}
|
|
91
|
+
return n;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The video a completed pull makes (the Video Object, as the open lane makes them), public; its id. */
|
|
95
|
+
export async function madeVideo(ctx: HandlerContext, publishId: string, post: Row, person: Row): Promise<string> {
|
|
96
|
+
const videoId = String(7_400_000_000_000_000_000 + (await next(ctx, 'video')) * 1024);
|
|
97
|
+
const caption = typeof post.caption === 'string' ? post.caption : '';
|
|
98
|
+
await ctx.write(VIDEO, videoId, {
|
|
99
|
+
id: videoId, create_time: seconds(ctx.occurredAt), _moment: videoMoment(ctx, String(person.id)), cover_image_url: mediaUrl(ctx, `cover-${videoId}`), share_url: `https://www.tiktok.com/@${String(person.username)}/video/${videoId}`,
|
|
100
|
+
video_description: caption.slice(0, 150), duration: 0, height: 0, width: 0, title: caption.slice(0, 150),
|
|
101
|
+
embed_html: `<blockquote class="tiktok-embed" cite="https://www.tiktok.com/@${String(person.username)}/video/${videoId}" data-video-id="${videoId}"></blockquote>`,
|
|
102
|
+
embed_link: `https://www.tiktok.com/player/v1/${videoId}`, like_count: 0, comment_count: 0, share_count: 0, view_count: 0, is_aigc: post.is_ai_generated === true,
|
|
103
|
+
_person: person.id, _public: true, _publish: publishId,
|
|
104
|
+
}, 'video.publish');
|
|
105
|
+
return videoId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function publish(ctx: HandlerContext, media: 'VIDEO' | 'PHOTO', urls: string[], post: Row, draft: boolean, person: Row): Promise<Response> {
|
|
109
|
+
const n = await next(ctx, 'publish');
|
|
110
|
+
const id = `${media === 'VIDEO' ? 'v' : 'p'}_pub_url~v2.${n}`;
|
|
111
|
+
await ctx.write(PUBLISH, id, { status: 'PROCESSING_DOWNLOAD', post_ids: [], _person: person.id, _media: media, _urls: urls, _post_info: post, _draft: draft, _lane: 'business' }, media === 'VIDEO' ? 'businessVideoPublish' : 'businessPhotoPublish');
|
|
112
|
+
const pulled = await pullAll(ctx, urls);
|
|
113
|
+
const to = pulled === undefined ? 'FAILED' : draft ? 'SEND_TO_USER_INBOX' : 'PUBLISH_COMPLETE';
|
|
114
|
+
const refused = ctx.legal(PUBLISH, 'status', 'publish.pull', 'PROCESSING_DOWNLOAD', to, id, 'vendor');
|
|
115
|
+
if (refused) throw new Error(refused.message);
|
|
116
|
+
const postIds = to === 'PUBLISH_COMPLETE' && media === 'VIDEO' ? [await madeVideo(ctx, id, post, person)] : [];
|
|
117
|
+
await ctx.write(PUBLISH, id, { status: to, post_ids: postIds, ...(to === 'FAILED' ? { reason: media === 'VIDEO' ? 'video_pull_failed' : 'photo_pull_failed' } : {}) }, 'publish.pull');
|
|
118
|
+
return ok(ctx, { share_id: id });
|
|
119
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "GENERATED by scripts/pack-facts.ts — current pack descriptors and explicit world-runtime server backings; no prior-artifact entries are retained.",
|
|
3
|
+
"packs": {
|
|
4
|
+
"tiktok": {
|
|
5
|
+
"adoption": {
|
|
6
|
+
"sdks": [],
|
|
7
|
+
"envStems": [
|
|
8
|
+
"TIKTOK"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"hosts": [
|
|
12
|
+
{
|
|
13
|
+
"host": "business-api.tiktok.com"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"host": "open.tiktokapis.com"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"host": "open-upload.tiktokapis.com"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"host": "upload.us.tiktokapis.com"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"host": "www.tiktok.com",
|
|
26
|
+
"pathPattern": "^/v2/auth/authorize"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"archetype": "crud",
|
|
30
|
+
"protocol": {
|
|
31
|
+
"declared": "3",
|
|
32
|
+
"major": 3,
|
|
33
|
+
"standing": "current"
|
|
34
|
+
},
|
|
35
|
+
"transport": "rest",
|
|
36
|
+
"resources": [
|
|
37
|
+
"publish",
|
|
38
|
+
"video"
|
|
39
|
+
],
|
|
40
|
+
"endpointEnvNone": "Postiz calls open.tiktokapis.com and sends people to www.tiktok.com; the World claims the hosts",
|
|
41
|
+
"credentialDoor": {
|
|
42
|
+
"path": "/_twin/app-credentials",
|
|
43
|
+
"body": {},
|
|
44
|
+
"fill": {
|
|
45
|
+
"TIKTOK_CLIENT_ID": "client_key",
|
|
46
|
+
"TIKTOK_CLIENT_SECRET": "client_secret",
|
|
47
|
+
"TIKTOK_BUSINESS_CLIENT_ID": "business_client_id",
|
|
48
|
+
"TIKTOK_BUSINESS_CLIENT_SECRET": "business_client_secret"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"serveExport": "createTiktokTwinServer"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"undeclared": []
|
|
55
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
{
|
|
2
|
+
"vendor": "tiktok",
|
|
3
|
+
"missing": 0,
|
|
4
|
+
"stale": [],
|
|
5
|
+
"code": {
|
|
6
|
+
"lines": 851,
|
|
7
|
+
"run": 849,
|
|
8
|
+
"recorded": 2,
|
|
9
|
+
"missing": 0
|
|
10
|
+
},
|
|
11
|
+
"state": {
|
|
12
|
+
"declared": 6,
|
|
13
|
+
"reached": 6,
|
|
14
|
+
"recorded": 0,
|
|
15
|
+
"missing": [],
|
|
16
|
+
"undeclared": []
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"file": "semantics/shared.ts",
|
|
21
|
+
"lines": {
|
|
22
|
+
"found": 39,
|
|
23
|
+
"hit": 39
|
|
24
|
+
},
|
|
25
|
+
"missing": []
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"file": "manifest.ts",
|
|
29
|
+
"lines": {
|
|
30
|
+
"found": 28,
|
|
31
|
+
"hit": 28
|
|
32
|
+
},
|
|
33
|
+
"missing": []
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"file": "index.ts",
|
|
37
|
+
"lines": {
|
|
38
|
+
"found": 10,
|
|
39
|
+
"hit": 10
|
|
40
|
+
},
|
|
41
|
+
"missing": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"file": "server.ts",
|
|
45
|
+
"lines": {
|
|
46
|
+
"found": 4,
|
|
47
|
+
"hit": 2
|
|
48
|
+
},
|
|
49
|
+
"missing": []
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"file": "fetch.ts",
|
|
53
|
+
"lines": {
|
|
54
|
+
"found": 6,
|
|
55
|
+
"hit": 6
|
|
56
|
+
},
|
|
57
|
+
"missing": []
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"file": "../business/src/semantics/states.ts",
|
|
61
|
+
"lines": {
|
|
62
|
+
"found": 2,
|
|
63
|
+
"hit": 2
|
|
64
|
+
},
|
|
65
|
+
"missing": []
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"file": "../business/src/semantics/shared.ts",
|
|
69
|
+
"lines": {
|
|
70
|
+
"found": 78,
|
|
71
|
+
"hit": 78
|
|
72
|
+
},
|
|
73
|
+
"missing": []
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"file": "../business/src/semantics/open_api.ts",
|
|
77
|
+
"lines": {
|
|
78
|
+
"found": 105,
|
|
79
|
+
"hit": 105
|
|
80
|
+
},
|
|
81
|
+
"missing": []
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"file": "../business/src/semantics/index.ts",
|
|
85
|
+
"lines": {
|
|
86
|
+
"found": 12,
|
|
87
|
+
"hit": 12
|
|
88
|
+
},
|
|
89
|
+
"missing": []
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"file": "../business/src/manifest.ts",
|
|
93
|
+
"lines": {
|
|
94
|
+
"found": 27,
|
|
95
|
+
"hit": 27
|
|
96
|
+
},
|
|
97
|
+
"missing": []
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"file": "../business/src/index.ts",
|
|
101
|
+
"lines": {
|
|
102
|
+
"found": 2,
|
|
103
|
+
"hit": 2
|
|
104
|
+
},
|
|
105
|
+
"missing": []
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"file": "../business/src/fetch.ts",
|
|
109
|
+
"lines": {
|
|
110
|
+
"found": 10,
|
|
111
|
+
"hit": 10
|
|
112
|
+
},
|
|
113
|
+
"missing": []
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"file": "../open/src/semantics/doors.ts",
|
|
117
|
+
"lines": {
|
|
118
|
+
"found": 81,
|
|
119
|
+
"hit": 81
|
|
120
|
+
},
|
|
121
|
+
"missing": []
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"file": "../open/src/semantics/oauth.ts",
|
|
125
|
+
"lines": {
|
|
126
|
+
"found": 21,
|
|
127
|
+
"hit": 21
|
|
128
|
+
},
|
|
129
|
+
"missing": []
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"file": "../open/src/semantics/video.ts",
|
|
133
|
+
"lines": {
|
|
134
|
+
"found": 76,
|
|
135
|
+
"hit": 76
|
|
136
|
+
},
|
|
137
|
+
"missing": []
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"file": "../open/src/semantics/states.ts",
|
|
141
|
+
"lines": {
|
|
142
|
+
"found": 11,
|
|
143
|
+
"hit": 11
|
|
144
|
+
},
|
|
145
|
+
"missing": []
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"file": "../open/src/semantics/shared.ts",
|
|
149
|
+
"lines": {
|
|
150
|
+
"found": 126,
|
|
151
|
+
"hit": 126
|
|
152
|
+
},
|
|
153
|
+
"missing": []
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"file": "../open/src/semantics/index.ts",
|
|
157
|
+
"lines": {
|
|
158
|
+
"found": 16,
|
|
159
|
+
"hit": 16
|
|
160
|
+
},
|
|
161
|
+
"missing": []
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"file": "../open/src/semantics/user.ts",
|
|
165
|
+
"lines": {
|
|
166
|
+
"found": 18,
|
|
167
|
+
"hit": 18
|
|
168
|
+
},
|
|
169
|
+
"missing": []
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"file": "../open/src/semantics/post.ts",
|
|
173
|
+
"lines": {
|
|
174
|
+
"found": 39,
|
|
175
|
+
"hit": 39
|
|
176
|
+
},
|
|
177
|
+
"missing": []
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"file": "../open/src/screens/authorize.ts",
|
|
181
|
+
"lines": {
|
|
182
|
+
"found": 39,
|
|
183
|
+
"hit": 39
|
|
184
|
+
},
|
|
185
|
+
"missing": []
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"file": "../open/src/manifest.ts",
|
|
189
|
+
"lines": {
|
|
190
|
+
"found": 45,
|
|
191
|
+
"hit": 45
|
|
192
|
+
},
|
|
193
|
+
"missing": []
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"file": "../open/src/index.ts",
|
|
197
|
+
"lines": {
|
|
198
|
+
"found": 2,
|
|
199
|
+
"hit": 2
|
|
200
|
+
},
|
|
201
|
+
"missing": []
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"file": "../open/src/engine/mp4.ts",
|
|
205
|
+
"lines": {
|
|
206
|
+
"found": 40,
|
|
207
|
+
"hit": 40
|
|
208
|
+
},
|
|
209
|
+
"missing": []
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"file": "../open/src/fetch.ts",
|
|
213
|
+
"lines": {
|
|
214
|
+
"found": 14,
|
|
215
|
+
"hit": 14
|
|
216
|
+
},
|
|
217
|
+
"missing": []
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
}
|