@sprid/scout 0.1.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/LICENSE +14 -0
- package/README.md +115 -0
- package/package.json +32 -0
- package/src/ads/cli.mjs +82 -0
- package/src/ads/extract.mjs +108 -0
- package/src/ads/harvest.mjs +90 -0
- package/src/ads/push.mjs +56 -0
- package/src/ads/run.js +67 -0
- package/src/analyze.js +155 -0
- package/src/chrome.js +396 -0
- package/src/config.js +83 -0
- package/src/discover.js +463 -0
- package/src/download.js +324 -0
- package/src/frames.js +85 -0
- package/src/jobs.js +370 -0
- package/src/login-cli.js +30 -0
- package/src/push-to-sprid.js +194 -0
- package/src/quick.js +318 -0
- package/src/register.js +27 -0
- package/src/server.js +881 -0
- package/src/sheet.js +106 -0
- package/src/store.js +130 -0
- package/src/tiktok.js +391 -0
package/src/analyze.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { acquire, handleChallenge, invoke } = require("./chrome");
|
|
4
|
+
const { parseCount, loadAccountDetails, measurePosts, postSummary } = require("./tiktok");
|
|
5
|
+
const jobsLib = require("./jobs");
|
|
6
|
+
|
|
7
|
+
const TOP_POSTS = 10;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scroll a profile and collect the posts the page fetches for itself. Returns
|
|
11
|
+
* the raw items, so every caller derives its own numbers from the same reading.
|
|
12
|
+
*/
|
|
13
|
+
async function readProfilePosts({ page, username, log, gate, challenge, maxScrolls, until }) {
|
|
14
|
+
const posts = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
let user = {};
|
|
17
|
+
|
|
18
|
+
const onResponse = async (response) => {
|
|
19
|
+
const url = response.url();
|
|
20
|
+
if (url.includes("/api/post/item_list/") || url.includes("/api/user/post/")) {
|
|
21
|
+
try {
|
|
22
|
+
const data = await response.json();
|
|
23
|
+
for (const item of data.itemList ?? data.items ?? []) {
|
|
24
|
+
if (item.id && !seen.has(item.id)) {
|
|
25
|
+
seen.add(item.id);
|
|
26
|
+
posts.push(item);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
if (url.includes("/api/user/detail/")) {
|
|
32
|
+
try {
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
if (data.userInfo?.user) user = { ...user, ...data.userInfo.user };
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
page.on("response", onResponse);
|
|
39
|
+
|
|
40
|
+
let domFollowers = 0;
|
|
41
|
+
try {
|
|
42
|
+
await page.goto("about:blank", { waitUntil: "domcontentloaded", timeout: 5000 });
|
|
43
|
+
await page.goto(`https://www.tiktok.com/@${username}`, {
|
|
44
|
+
waitUntil: "domcontentloaded",
|
|
45
|
+
timeout: 20_000,
|
|
46
|
+
});
|
|
47
|
+
await page.waitForTimeout(3000);
|
|
48
|
+
await handleChallenge(page, log, challenge);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await page.waitForSelector('[data-e2e="followers-count"]', { timeout: 3000 });
|
|
52
|
+
const raw = await page.evaluate(
|
|
53
|
+
invoke(`() => {
|
|
54
|
+
const el = document.querySelector('[data-e2e="followers-count"]');
|
|
55
|
+
return el ? (el.textContent || el.innerText || '').trim() : '';
|
|
56
|
+
}`)
|
|
57
|
+
);
|
|
58
|
+
domFollowers = parseCount(raw || "");
|
|
59
|
+
} catch {}
|
|
60
|
+
|
|
61
|
+
for (let scroll = 0; scroll < maxScrolls; scroll++) {
|
|
62
|
+
if (!(await gate())) break;
|
|
63
|
+
const before = posts.length;
|
|
64
|
+
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)");
|
|
65
|
+
await page.waitForTimeout(2500);
|
|
66
|
+
await handleChallenge(page, log, challenge);
|
|
67
|
+
log(` Scroll ${scroll + 1}: ${posts.length} posts loaded`);
|
|
68
|
+
if (posts.length === before && scroll >= 1) {
|
|
69
|
+
log(" No new posts: profile fully loaded");
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
// Stop early once the reading is old enough to answer the question asked.
|
|
73
|
+
if (until && posts.length > 0) {
|
|
74
|
+
const oldest = Math.min(...posts.map((p) => p.createTime ?? Infinity));
|
|
75
|
+
if (Number.isFinite(oldest) && oldest < until) {
|
|
76
|
+
log(" Reached the far edge of the window.");
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
page.off("response", onResponse);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { posts, user, domFollowers };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runAnalysis(job) {
|
|
89
|
+
const { username, days, allVideos } = job.meta;
|
|
90
|
+
const log = (text, alert) => jobsLib.log(job, text, alert);
|
|
91
|
+
const gate = jobsLib.gate(job);
|
|
92
|
+
const session = await acquire();
|
|
93
|
+
const challenge = {
|
|
94
|
+
hold: gate,
|
|
95
|
+
signal: session.challengeSignal,
|
|
96
|
+
onDetected: (prompt) => jobsLib.setAlert(job, "captcha", prompt),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
log(`Opening @${username} and reading ${allVideos ? "the whole account" : `the last ${days} days`}.`);
|
|
101
|
+
const until = allVideos ? null : Date.now() / 1000 - days * 86400;
|
|
102
|
+
const { posts, user, domFollowers } = await readProfilePosts({
|
|
103
|
+
page: session.page,
|
|
104
|
+
username,
|
|
105
|
+
log,
|
|
106
|
+
gate,
|
|
107
|
+
challenge,
|
|
108
|
+
maxScrolls: allVideos ? 60 : 20,
|
|
109
|
+
until,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (posts.length === 0) {
|
|
113
|
+
jobsLib.setAlert(
|
|
114
|
+
job,
|
|
115
|
+
"unreadable",
|
|
116
|
+
`Nothing loaded for @${username}. The account is usually private, renamed, or removed.`
|
|
117
|
+
);
|
|
118
|
+
throw new Error(`No posts could be read for @${username}.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const details = await loadAccountDetails(username);
|
|
122
|
+
const followers =
|
|
123
|
+
details.followerCount || domFollowers || user.followerCount || posts[0]?.author?.followerCount || 0;
|
|
124
|
+
|
|
125
|
+
const windowDays = allVideos ? 3650 : days;
|
|
126
|
+
const measured = measurePosts({ posts, followers, avgViewsDays: windowDays, details });
|
|
127
|
+
if (!measured) throw new Error(`Post timestamps could not be read for @${username}.`);
|
|
128
|
+
|
|
129
|
+
const cutoff = allVideos ? 0 : Date.now() / 1000 - days * 86400;
|
|
130
|
+
const inWindow = posts.filter((p) => (p.createTime ?? 0) >= cutoff);
|
|
131
|
+
const topPosts = [...inWindow]
|
|
132
|
+
.sort((a, b) => (b.stats?.playCount ?? 0) - (a.stats?.playCount ?? 0))
|
|
133
|
+
.slice(0, TOP_POSTS)
|
|
134
|
+
.map(postSummary);
|
|
135
|
+
|
|
136
|
+
log(`Read ${posts.length} posts, ${measured.metrics.slideshow_count} of them slideshows.`);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
username,
|
|
140
|
+
display_name: details.nickname || user.nickname || "",
|
|
141
|
+
bio: details.signature ?? user.signature ?? "",
|
|
142
|
+
verified: details.verified ?? Boolean(user.verified),
|
|
143
|
+
followers,
|
|
144
|
+
window_days: allVideos ? null : days,
|
|
145
|
+
all_videos: Boolean(allVideos),
|
|
146
|
+
metrics: measured.metrics,
|
|
147
|
+
sample_captions: measured.captions,
|
|
148
|
+
top_posts: topPosts,
|
|
149
|
+
};
|
|
150
|
+
} finally {
|
|
151
|
+
await session.release();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { runAnalysis, readProfilePosts, TOP_POSTS };
|
package/src/chrome.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const util = require("node:util");
|
|
6
|
+
const { execFile } = require("node:child_process");
|
|
7
|
+
// Plain playwright, driving the user's own Chrome with their own profile. There
|
|
8
|
+
// is deliberately no bot-detection evasion here: this reads pages the person
|
|
9
|
+
// could open themselves, and a dependency whose purpose is hiding that would
|
|
10
|
+
// say something about the product that is not true.
|
|
11
|
+
const { chromium } = require("playwright-core");
|
|
12
|
+
const { chromeBinary, PROFILE_DIR } = require("./config");
|
|
13
|
+
|
|
14
|
+
const run = util.promisify(execFile);
|
|
15
|
+
|
|
16
|
+
const TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York";
|
|
17
|
+
|
|
18
|
+
const LAUNCH_ARGS = [
|
|
19
|
+
"--no-first-run",
|
|
20
|
+
"--no-default-browser-check",
|
|
21
|
+
"--disable-blink-features=AutomationControlled",
|
|
22
|
+
"--disable-infobars",
|
|
23
|
+
"--lang=en-US",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// TikTok renders its server payload into one script tag and then removes it on
|
|
27
|
+
// hydration. Stashing it the moment it appears means a later read still has it.
|
|
28
|
+
const STASH_REHYDRATION = `(() => {
|
|
29
|
+
const ID = '__UNIVERSAL_DATA_FOR_REHYDRATION__';
|
|
30
|
+
const stash = () => {
|
|
31
|
+
const el = document.getElementById(ID);
|
|
32
|
+
if (el && el.textContent) { window.__SS_REHYDRATION__ = el.textContent; return true; }
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
if (stash()) return;
|
|
36
|
+
const start = () => {
|
|
37
|
+
if (stash()) return;
|
|
38
|
+
const root = document.documentElement || document;
|
|
39
|
+
const observer = new MutationObserver(() => { if (stash()) observer.disconnect(); });
|
|
40
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
41
|
+
document.addEventListener('DOMContentLoaded', stash);
|
|
42
|
+
};
|
|
43
|
+
if (document.documentElement) start();
|
|
44
|
+
else document.addEventListener('readystatechange', start, { once: true });
|
|
45
|
+
})()`;
|
|
46
|
+
|
|
47
|
+
function invoke(probe) {
|
|
48
|
+
return `(${probe})()`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CAPTCHA_PROBE = `() => {
|
|
52
|
+
const visible = (el) => {
|
|
53
|
+
if (!el) return false;
|
|
54
|
+
const r = el.getBoundingClientRect();
|
|
55
|
+
if (r.width < 40 || r.height < 40) return false;
|
|
56
|
+
const st = getComputedStyle(el);
|
|
57
|
+
return st.display !== 'none' && st.visibility !== 'hidden' && parseFloat(st.opacity || '1') > 0.05;
|
|
58
|
+
};
|
|
59
|
+
const containerSel = [
|
|
60
|
+
'#captcha-verify-container', '.captcha_verify_container', '.captcha-verify-container',
|
|
61
|
+
'#captcha_container', '#captcha-verify-image', '[id^="captcha"]',
|
|
62
|
+
'[class*="captcha_verify"]', '[class*="captcha-verify"]',
|
|
63
|
+
];
|
|
64
|
+
let node = null;
|
|
65
|
+
for (const s of containerSel) {
|
|
66
|
+
for (const el of document.querySelectorAll(s)) {
|
|
67
|
+
if (visible(el)) { node = el; break; }
|
|
68
|
+
}
|
|
69
|
+
if (node) break;
|
|
70
|
+
}
|
|
71
|
+
if (!node) return null;
|
|
72
|
+
const promptSel = [
|
|
73
|
+
'.captcha_verify_bar', '.cap-flex-center', '[class*="verify_message"]',
|
|
74
|
+
'[class*="Title"]', '[class*="title"]', '.TUXText',
|
|
75
|
+
];
|
|
76
|
+
const root = node.closest('[id*="captcha"],[class*="captcha"]') || node;
|
|
77
|
+
for (const s of promptSel) {
|
|
78
|
+
const p = root.querySelector(s) || document.querySelector(s);
|
|
79
|
+
if (p && p.innerText && p.innerText.trim().length > 3) return p.innerText.trim();
|
|
80
|
+
}
|
|
81
|
+
const txt = (root.innerText || '').trim().split('\\n').map(t => t.trim()).filter(Boolean);
|
|
82
|
+
return txt.length ? txt.slice(0, 2).join(': ') : 'TikTok verification challenge';
|
|
83
|
+
}`;
|
|
84
|
+
|
|
85
|
+
const LOGIN_PROBE = `() => {
|
|
86
|
+
const visible = (el) => {
|
|
87
|
+
if (!el) return false;
|
|
88
|
+
const r = el.getBoundingClientRect();
|
|
89
|
+
if (r.width < 20 || r.height < 20) return false;
|
|
90
|
+
const st = getComputedStyle(el);
|
|
91
|
+
return st.display !== 'none' && st.visibility !== 'hidden' && parseFloat(st.opacity || '1') > 0.05;
|
|
92
|
+
};
|
|
93
|
+
let modal = null;
|
|
94
|
+
for (const h of document.querySelectorAll('h1, h2, [class*="Title"], [class*="title"]')) {
|
|
95
|
+
if (visible(h) && /log ?in to tiktok/i.test(h.textContent || '')) {
|
|
96
|
+
modal = h.closest('[role="dialog"], div[class*="Modal"], div[class*="modal"]') || h.parentElement;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!modal) {
|
|
101
|
+
for (const s of ['#loginContainer', '[id*="login-modal" i]', '[class*="LoginModal"]']) {
|
|
102
|
+
const el = document.querySelector(s);
|
|
103
|
+
if (el && visible(el)) { modal = el; break; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!modal) return false;
|
|
107
|
+
const closeSel = [
|
|
108
|
+
'[data-e2e="modal-close-inner-button"]',
|
|
109
|
+
'[aria-label="Close"]', '[aria-label="close"]',
|
|
110
|
+
'[class*="CloseWrapper"]', '[class*="closeButton" i]', 'button[class*="Close"]',
|
|
111
|
+
];
|
|
112
|
+
for (const s of closeSel) {
|
|
113
|
+
const btn = document.querySelector(s);
|
|
114
|
+
if (btn && visible(btn)) { btn.click(); return true; }
|
|
115
|
+
}
|
|
116
|
+
const inner = modal.querySelectorAll('button, [role="button"], div[class*="Close"], svg');
|
|
117
|
+
for (const b of inner) { if (visible(b)) { b.click(); return true; } }
|
|
118
|
+
return true;
|
|
119
|
+
}`;
|
|
120
|
+
|
|
121
|
+
const TEXT_PROBE = `() => (document.body ? (document.body.innerText || '') : '').slice(0, 4000)`;
|
|
122
|
+
|
|
123
|
+
const CHALLENGE_REQUEST = /\/captcha\/(get|verify)\b/;
|
|
124
|
+
const VERIFY_TEXT =
|
|
125
|
+
/verify to continue|verification|drag the (slider|puzzle)|slide to|select the .* (shape|object|image)|too many attempts|check you are (a )?human/i;
|
|
126
|
+
|
|
127
|
+
function watchChallengeRequests(page, signal = { lastRequestAt: 0 }) {
|
|
128
|
+
page.on("request", (request) => {
|
|
129
|
+
if (CHALLENGE_REQUEST.test(request.url())) signal.lastRequestAt = Date.now();
|
|
130
|
+
});
|
|
131
|
+
return signal;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requestedRecently(signal) {
|
|
135
|
+
return Boolean(signal && Date.now() - signal.lastRequestAt < 30_000);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function detectCaptcha(page) {
|
|
139
|
+
try {
|
|
140
|
+
const overlay = await page.evaluate(invoke(CAPTCHA_PROBE));
|
|
141
|
+
if (overlay) return overlay;
|
|
142
|
+
const bodyText = await page.evaluate(invoke(TEXT_PROBE));
|
|
143
|
+
const match = bodyText.match(VERIFY_TEXT);
|
|
144
|
+
if (match && bodyText.length < 1500) {
|
|
145
|
+
return bodyText
|
|
146
|
+
.split("\n")
|
|
147
|
+
.map((line) => line.trim())
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.slice(0, 2)
|
|
150
|
+
.join(": ");
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function detectCaptchaAnywhere(page) {
|
|
159
|
+
const own = await detectCaptcha(page);
|
|
160
|
+
if (own) return { prompt: own, page };
|
|
161
|
+
for (const other of page.context().pages()) {
|
|
162
|
+
if (other === page || other.isClosed()) continue;
|
|
163
|
+
const prompt = await detectCaptcha(other);
|
|
164
|
+
if (prompt) return { prompt, page: other };
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function dismissLogin(page, log) {
|
|
170
|
+
let found = false;
|
|
171
|
+
try {
|
|
172
|
+
found = await page.evaluate(invoke(LOGIN_PROBE));
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!found) return false;
|
|
177
|
+
log("Login modal detected: closing and reloading");
|
|
178
|
+
try {
|
|
179
|
+
await page.waitForTimeout(400);
|
|
180
|
+
await page.reload({ waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
181
|
+
await page.waitForTimeout(2000);
|
|
182
|
+
} catch {}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function focusChromeWindow() {
|
|
187
|
+
await run("osascript", ["-e", 'tell application "Google Chrome" to activate']).catch(
|
|
188
|
+
() => {}
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Three independent signals, because any one of them alone misses cases: the
|
|
194
|
+
* captcha network calls, the overlay in the DOM, and the words on the page.
|
|
195
|
+
* Returns "resumed" or "stopped" when a challenge was handled, null otherwise.
|
|
196
|
+
*/
|
|
197
|
+
async function handleChallenge(page, log, hooks = {}) {
|
|
198
|
+
await dismissLogin(page, log);
|
|
199
|
+
const found = await detectCaptchaAnywhere(page);
|
|
200
|
+
const fromNetwork = requestedRecently(hooks.signal);
|
|
201
|
+
if (!found && !fromNetwork) return null;
|
|
202
|
+
const prompt = found?.prompt ?? "TikTok verification challenge";
|
|
203
|
+
log(`CAPTCHA_DETECTED: ${prompt}`, true);
|
|
204
|
+
log("Solve the verification in the Chrome window, then resume the job.");
|
|
205
|
+
await (found?.page ?? page).bringToFront().catch(() => {});
|
|
206
|
+
await focusChromeWindow();
|
|
207
|
+
hooks.onDetected?.(prompt);
|
|
208
|
+
const outcome = hooks.hold ? ((await hooks.hold()) ? "resumed" : "stopped") : "resumed";
|
|
209
|
+
if (hooks.signal) hooks.signal.lastRequestAt = 0;
|
|
210
|
+
return outcome;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function trackFeedRequests(page, pending) {
|
|
214
|
+
const isFeed = (url) =>
|
|
215
|
+
url.includes("/api/post/item_list/") || url.includes("/api/user/post/");
|
|
216
|
+
page.on("request", (request) => {
|
|
217
|
+
if (isFeed(request.url())) pending.count += 1;
|
|
218
|
+
});
|
|
219
|
+
const done = (request) => {
|
|
220
|
+
if (isFeed(request.url())) pending.count = Math.max(0, pending.count - 1);
|
|
221
|
+
};
|
|
222
|
+
page.on("requestfinished", done);
|
|
223
|
+
page.on("requestfailed", done);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function killChrome(profileDir) {
|
|
227
|
+
const pattern = `user-data-dir=${profileDir}`;
|
|
228
|
+
await run("pkill", ["-9", "-f", pattern]).catch(() => {});
|
|
229
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
230
|
+
const alive = await run("pgrep", ["-f", pattern]).then(
|
|
231
|
+
() => true,
|
|
232
|
+
() => false
|
|
233
|
+
);
|
|
234
|
+
if (!alive) break;
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
236
|
+
}
|
|
237
|
+
for (const lock of ["SingletonLock", "SingletonCookie", "SingletonSocket"]) {
|
|
238
|
+
try {
|
|
239
|
+
fs.unlinkSync(path.join(profileDir, lock));
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let live = null;
|
|
245
|
+
let starting = null;
|
|
246
|
+
const ownedPages = new Set();
|
|
247
|
+
let activeLeases = 0;
|
|
248
|
+
|
|
249
|
+
async function launch(profileDir) {
|
|
250
|
+
await killChrome(profileDir);
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
252
|
+
const context = await chromium.launchPersistentContext(profileDir, {
|
|
253
|
+
executablePath: chromeBinary(),
|
|
254
|
+
headless: false,
|
|
255
|
+
// Let Chrome own its window size, the way a person's browser does. A fixed
|
|
256
|
+
// viewport is a giveaway and fights the real window.
|
|
257
|
+
viewport: null,
|
|
258
|
+
locale: "en-US",
|
|
259
|
+
timezoneId: TIMEZONE,
|
|
260
|
+
args: LAUNCH_ARGS,
|
|
261
|
+
});
|
|
262
|
+
await context.addInitScript(STASH_REHYDRATION);
|
|
263
|
+
return { browser: context.browser(), context, profileDir };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function ensure(profileDir) {
|
|
267
|
+
if (live && (live.browser?.isConnected() ?? false)) return live;
|
|
268
|
+
live = null;
|
|
269
|
+
starting ??= launch(profileDir)
|
|
270
|
+
.then((started) => {
|
|
271
|
+
live = started;
|
|
272
|
+
return started;
|
|
273
|
+
})
|
|
274
|
+
.finally(() => {
|
|
275
|
+
starting = null;
|
|
276
|
+
});
|
|
277
|
+
return starting;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One Chrome, many leases. Each caller gets its own pages and closes only
|
|
282
|
+
* those; the browser goes down when the last lease is released.
|
|
283
|
+
*/
|
|
284
|
+
async function acquire(profileDir = PROFILE_DIR) {
|
|
285
|
+
activeLeases++;
|
|
286
|
+
let context;
|
|
287
|
+
try {
|
|
288
|
+
({ context } = await ensure(profileDir));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
activeLeases = Math.max(0, activeLeases - 1);
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
const owned = [];
|
|
294
|
+
const challengeSignal = { lastRequestAt: 0 };
|
|
295
|
+
const open = async () => {
|
|
296
|
+
const idle = context
|
|
297
|
+
.pages()
|
|
298
|
+
.find((p) => !ownedPages.has(p) && (p.url() === "about:blank" || p.url() === ""));
|
|
299
|
+
const page = idle ?? (await context.newPage());
|
|
300
|
+
ownedPages.add(page);
|
|
301
|
+
page.once("close", () => ownedPages.delete(page));
|
|
302
|
+
watchChallengeRequests(page, challengeSignal);
|
|
303
|
+
owned.push(page);
|
|
304
|
+
return page;
|
|
305
|
+
};
|
|
306
|
+
const page = await open();
|
|
307
|
+
let released = false;
|
|
308
|
+
return {
|
|
309
|
+
context,
|
|
310
|
+
page,
|
|
311
|
+
newPage: open,
|
|
312
|
+
challengeSignal,
|
|
313
|
+
async release() {
|
|
314
|
+
if (released) return;
|
|
315
|
+
released = true;
|
|
316
|
+
await Promise.all(owned.map((p) => p.close().catch(() => {})));
|
|
317
|
+
activeLeases = Math.max(0, activeLeases - 1);
|
|
318
|
+
if (activeLeases === 0) await shutdown();
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function shutdown() {
|
|
324
|
+
activeLeases = 0;
|
|
325
|
+
const running = live;
|
|
326
|
+
live = null;
|
|
327
|
+
if (!running) return;
|
|
328
|
+
await running.context.close().catch(() => {});
|
|
329
|
+
await killChrome(running.profileDir);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function cookieStores(dir = PROFILE_DIR) {
|
|
333
|
+
return [
|
|
334
|
+
path.join(dir, "Default", "Cookies"),
|
|
335
|
+
path.join(dir, "Default", "Network", "Cookies"),
|
|
336
|
+
].flatMap((base) => [base, `${base}-journal`, `${base}-wal`, `${base}-shm`]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Read the cookie files as bytes. Cheap, and it needs no browser running. */
|
|
340
|
+
function isLoggedIn() {
|
|
341
|
+
if (!fs.existsSync(PROFILE_DIR)) return false;
|
|
342
|
+
return cookieStores().some((file) => {
|
|
343
|
+
try {
|
|
344
|
+
const bytes = fs.readFileSync(file);
|
|
345
|
+
return bytes.includes("tiktok") && (bytes.includes("sessionid") || bytes.includes("sid_tt"));
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function signOut() {
|
|
353
|
+
await shutdown();
|
|
354
|
+
await killChrome(PROFILE_DIR);
|
|
355
|
+
for (const file of cookieStores()) {
|
|
356
|
+
try {
|
|
357
|
+
fs.rmSync(file, { force: true });
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const LOGIN_URL = "https://www.tiktok.com/login";
|
|
363
|
+
|
|
364
|
+
/** Opens a real window and waits for the session cookie to appear. */
|
|
365
|
+
async function login(timeoutMs = 180_000) {
|
|
366
|
+
const session = await acquire();
|
|
367
|
+
try {
|
|
368
|
+
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded" });
|
|
369
|
+
await session.page.bringToFront().catch(() => {});
|
|
370
|
+
await focusChromeWindow();
|
|
371
|
+
const deadline = Date.now() + timeoutMs;
|
|
372
|
+
while (Date.now() < deadline) {
|
|
373
|
+
const cookies = await session.context.cookies("https://www.tiktok.com");
|
|
374
|
+
if (cookies.some((c) => c.name === "sessionid" || c.name === "sid_tt")) {
|
|
375
|
+
await session.page.waitForTimeout(1500);
|
|
376
|
+
return { loggedIn: true };
|
|
377
|
+
}
|
|
378
|
+
await session.page.waitForTimeout(1000);
|
|
379
|
+
}
|
|
380
|
+
return { loggedIn: false };
|
|
381
|
+
} finally {
|
|
382
|
+
await session.release();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
acquire,
|
|
388
|
+
shutdown,
|
|
389
|
+
handleChallenge,
|
|
390
|
+
trackFeedRequests,
|
|
391
|
+
invoke,
|
|
392
|
+
isLoggedIn,
|
|
393
|
+
signOut,
|
|
394
|
+
login,
|
|
395
|
+
focusChromeWindow,
|
|
396
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
|
|
7
|
+
/** The one place the product name lives. Rename here and everything follows. */
|
|
8
|
+
const NAME = "scout";
|
|
9
|
+
|
|
10
|
+
// Research filed under the old name keeps answering. The library is the user's
|
|
11
|
+
// own notebook, so it is read where it already is rather than moved under them.
|
|
12
|
+
const LEGACY_ROOT = path.join(os.homedir(), ".slidescout");
|
|
13
|
+
const CURRENT_ROOT = path.join(os.homedir(), `.${NAME}`);
|
|
14
|
+
const ROOT =
|
|
15
|
+
!fs.existsSync(CURRENT_ROOT) && fs.existsSync(LEGACY_ROOT) ? LEGACY_ROOT : CURRENT_ROOT;
|
|
16
|
+
const PROFILE_DIR = path.join(ROOT, "chrome-profile");
|
|
17
|
+
const RUNS_DIR = path.join(ROOT, "runs");
|
|
18
|
+
const LIBRARY_FILE = path.join(ROOT, "library.json");
|
|
19
|
+
const DOWNLOADS_DIR = path.join(ROOT, "downloads");
|
|
20
|
+
|
|
21
|
+
function ensureDirs() {
|
|
22
|
+
for (const dir of [ROOT, RUNS_DIR, DOWNLOADS_DIR]) {
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CHROME_CANDIDATES = [
|
|
28
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
29
|
+
`${os.homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
30
|
+
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
31
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
class ChromeMissingError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super(
|
|
37
|
+
"Google Chrome is not installed. This drives your own Chrome so that TikTok sees a real browser with your session: install it from google.com/chrome and try again."
|
|
38
|
+
);
|
|
39
|
+
this.name = "ChromeMissingError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let resolvedChrome = null;
|
|
44
|
+
function chromeBinary() {
|
|
45
|
+
if (resolvedChrome) return resolvedChrome;
|
|
46
|
+
const found = CHROME_CANDIDATES.find((candidate) => {
|
|
47
|
+
try {
|
|
48
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (!found) throw new ChromeMissingError();
|
|
55
|
+
resolvedChrome = found;
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function chromeUnavailable() {
|
|
60
|
+
try {
|
|
61
|
+
chromeBinary();
|
|
62
|
+
return null;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return error.message;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BROWSER_UA =
|
|
69
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
NAME,
|
|
73
|
+
ROOT,
|
|
74
|
+
PROFILE_DIR,
|
|
75
|
+
RUNS_DIR,
|
|
76
|
+
LIBRARY_FILE,
|
|
77
|
+
DOWNLOADS_DIR,
|
|
78
|
+
ensureDirs,
|
|
79
|
+
chromeBinary,
|
|
80
|
+
chromeUnavailable,
|
|
81
|
+
ChromeMissingError,
|
|
82
|
+
BROWSER_UA,
|
|
83
|
+
};
|