feat(wtrlab): selectable translation mode, sign-in, and genre/tag fix (#2525)
This commit is contained in:
+291
-94
@@ -3,22 +3,198 @@ import { fetchApi } from '@libs/fetch';
|
|||||||
import { FilterTypes, Filters } from '@libs/filterInputs';
|
import { FilterTypes, Filters } from '@libs/filterInputs';
|
||||||
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
import { CheerioAPI, load as parseHTML } from 'cheerio';
|
||||||
import { gcm } from '@libs/aes';
|
import { gcm } from '@libs/aes';
|
||||||
|
import { storage } from '@libs/storage';
|
||||||
|
|
||||||
class WTRLAB implements Plugin.PluginBase {
|
class WTRLAB implements Plugin.PluginBase {
|
||||||
id = 'WTRLAB';
|
id = 'WTRLAB';
|
||||||
name = 'WTR-LAB';
|
name = 'WTR-LAB';
|
||||||
site = 'https://wtr-lab.com/';
|
site = 'https://wtr-lab.com/';
|
||||||
version = '1.1.6';
|
version = '1.2.0';
|
||||||
icon = 'src/en/wtrlab/icon.png';
|
icon = 'src/en/wtrlab/icon.png';
|
||||||
sourceLang = 'en/';
|
sourceLang = 'en/';
|
||||||
baggage = '';
|
baggage = '';
|
||||||
trace = '';
|
trace = '';
|
||||||
|
|
||||||
|
pluginSettings = {
|
||||||
|
signInUrl: {
|
||||||
|
value: '',
|
||||||
|
label:
|
||||||
|
'Sign-in link — request "Continue with Email" on wtr-lab, then paste the full link from that email here (the plugin reads the token out of it and redeems it). Clear this field once AI chapters load; links are single-use and short-lived.',
|
||||||
|
type: 'Text',
|
||||||
|
},
|
||||||
|
sessionCookie: {
|
||||||
|
value: '',
|
||||||
|
label:
|
||||||
|
'Session cookie (fallback) — usually leave EMPTY. Android replaces this header with its own stored cookies whenever it has any, so the sign-in link above is the reliable route.',
|
||||||
|
type: 'Text',
|
||||||
|
},
|
||||||
|
preferredMode: {
|
||||||
|
value: 'ai',
|
||||||
|
label: 'Preferred translation',
|
||||||
|
type: 'Select',
|
||||||
|
options: [
|
||||||
|
{ label: 'AI', value: 'ai' },
|
||||||
|
{ label: 'Web+', value: 'webplus' },
|
||||||
|
{ label: 'Web', value: 'web' },
|
||||||
|
{ label: 'Custom — set the id below', value: 'custom' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
customMode: {
|
||||||
|
value: '',
|
||||||
|
label:
|
||||||
|
'Custom translation id — only used when "Custom" is selected above. This is the value wtr-lab sends as "translate" in its /api/reader/get request.',
|
||||||
|
type: 'Text',
|
||||||
|
},
|
||||||
|
fallbackToWeb: {
|
||||||
|
value: true,
|
||||||
|
label: 'Fall back to Web when the preferred translation is unavailable',
|
||||||
|
type: 'Switch',
|
||||||
|
},
|
||||||
|
showModeNotice: {
|
||||||
|
value: true,
|
||||||
|
label: 'Show which translation was used at the top of each chapter',
|
||||||
|
type: 'Switch',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full Cookie header value supplied by the user in plugin settings. */
|
||||||
|
get sessionCookie(): string {
|
||||||
|
return (storage.get<string>('sessionCookie') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only attempt the sign-in link once per app run: the links are single-use. */
|
||||||
|
signInAttempted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visits the user's emailed sign-in link once. Any Set-Cookie it returns is
|
||||||
|
* stored by Android's shared cookie jar, which every later request uses
|
||||||
|
* automatically — unlike a Cookie header, which the jar overrides.
|
||||||
|
*
|
||||||
|
* @returns a short status line to show the user, or null if nothing was tried.
|
||||||
|
*/
|
||||||
|
async ensureSignedIn(): Promise<string | null> {
|
||||||
|
const raw = (storage.get<string>('signInUrl') || '').trim();
|
||||||
|
if (!raw || this.signInAttempted) return null;
|
||||||
|
this.signInAttempted = true;
|
||||||
|
|
||||||
|
// A full URL must be wtr-lab's own; a bare token is accepted too.
|
||||||
|
if (
|
||||||
|
/^[a-z]+:\/\//i.test(raw) &&
|
||||||
|
!/^https:\/\/([a-z0-9-]+\.)*wtr-lab\.com\//i.test(raw)
|
||||||
|
) {
|
||||||
|
return 'Sign-in link ignored — it is not an https wtr-lab.com address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The emailed link points at a PAGE whose JavaScript redeems the token.
|
||||||
|
// Nothing runs that script here, so pull the token out and call the
|
||||||
|
// redeem endpoint directly.
|
||||||
|
let token = '';
|
||||||
|
const fromQuery = raw.match(/[?&]token=([^&\s]+)/);
|
||||||
|
if (fromQuery) {
|
||||||
|
token = decodeURIComponent(fromQuery[1]);
|
||||||
|
} else if (/^[A-Za-z0-9_.-]{16,}$/.test(raw)) {
|
||||||
|
token = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return 'Sign-in link ignored — no token found. Paste the whole link from the email (it contains "token=").';
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyUrl = `${this.site}api/auth/magic-link/verify?token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}&callbackURL=/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchApi(verifyUrl, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json,text/html,*/*',
|
||||||
|
'Referer': this.site,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const landedOn = (res.url || '').replace(this.site, '/') || 'unknown';
|
||||||
|
return `Sign-in: redeem token HTTP ${res.status}, ended at ${landedOn}`;
|
||||||
|
} catch (e) {
|
||||||
|
return `Sign-in failed: ${String(e)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks wtr-lab who it thinks we are. This is the ground truth for whether a
|
||||||
|
* session actually survived into the plugin's requests.
|
||||||
|
*/
|
||||||
|
async checkSession(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const cookie = this.sessionCookie;
|
||||||
|
const res = await fetchApi(`${this.site}api/auth/get-session`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
// wtr-lab nests this differently in places, so check the likely shapes.
|
||||||
|
const user =
|
||||||
|
body?.user ||
|
||||||
|
body?.session?.user ||
|
||||||
|
body?.data?.user ||
|
||||||
|
body?.session?.session?.user;
|
||||||
|
if (typeof user === 'string' && user) {
|
||||||
|
return `signed in (${user})`;
|
||||||
|
}
|
||||||
|
if (user && typeof user === 'object') {
|
||||||
|
const label =
|
||||||
|
user.user_name ||
|
||||||
|
user.name ||
|
||||||
|
user.username ||
|
||||||
|
user.email ||
|
||||||
|
user.id ||
|
||||||
|
'';
|
||||||
|
return label ? `signed in as ${label}` : 'signed in';
|
||||||
|
}
|
||||||
|
return `NOT signed in (get-session HTTP ${res.status}: ${
|
||||||
|
text
|
||||||
|
.slice(0, 60)
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.trim() || 'empty response'
|
||||||
|
})`;
|
||||||
|
} catch (e) {
|
||||||
|
return `session check failed: ${String(e)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translation modes to try, in order, based on plugin settings. */
|
||||||
|
get translationModes(): string[] {
|
||||||
|
const preferred = (storage.get<string>('preferredMode') || 'ai').trim();
|
||||||
|
const custom = (storage.get<string>('customMode') || '').trim();
|
||||||
|
const fallback = storage.get<boolean>('fallbackToWeb');
|
||||||
|
|
||||||
|
const modes: string[] = [];
|
||||||
|
if (preferred === 'custom') {
|
||||||
|
if (custom) modes.push(custom);
|
||||||
|
} else if (preferred) {
|
||||||
|
modes.push(preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `false` means the user turned it off; `undefined` means never set, so default to on.
|
||||||
|
if (fallback !== false && !modes.includes('web')) modes.push('web');
|
||||||
|
if (modes.length === 0) modes.push('web');
|
||||||
|
|
||||||
|
return modes;
|
||||||
|
}
|
||||||
|
|
||||||
get headers(): Record<string, string> {
|
get headers(): Record<string, string> {
|
||||||
return {
|
const headers: Record<string, string> = {
|
||||||
baggage: this.baggage,
|
baggage: this.baggage,
|
||||||
'sentry-trace': this.trace,
|
'sentry-trace': this.trace,
|
||||||
};
|
};
|
||||||
|
if (this.sessionCookie) headers.Cookie = this.sessionCookie;
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async popularNovels(
|
async popularNovels(
|
||||||
@@ -230,85 +406,47 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
loadedCheerio('.lead').text().trim();
|
loadedCheerio('.lead').text().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const genres =
|
// Genres and tags live in __NEXT_DATA__ as numeric ids, not in the page
|
||||||
loadedCheerio('td:contains("Genre")')
|
// markup. Tag names are served already resolved in pageProps.tags; genre
|
||||||
.next()
|
// names appear only as the text of their chip links.
|
||||||
.find('a')
|
const labels: string[] = [];
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray() ||
|
|
||||||
loadedCheerio('.genre')
|
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray() ||
|
|
||||||
loadedCheerio('.genres .genre')
|
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
if (genres.length > 0) {
|
if (nextDataText) {
|
||||||
novel.genres = genres
|
try {
|
||||||
.map(g => g.replace(/,$/, '').trim())
|
const tagData = JSON.parse(nextDataText);
|
||||||
.filter(genre => genre && genre.length > 0)
|
const pageProps = tagData?.props?.pageProps;
|
||||||
.join(', ');
|
const ids: number[] = pageProps?.serie?.serie_data?.genres || [];
|
||||||
|
|
||||||
|
const genreNames = new Map<number, string>();
|
||||||
|
loadedCheerio('a[href*="novel-list?genre="]').each((i, el) => {
|
||||||
|
const href = loadedCheerio(el).attr('href') || '';
|
||||||
|
const matched = href.match(/genre=(\d+)/);
|
||||||
|
const name = loadedCheerio(el).text().trim();
|
||||||
|
if (matched && name) genreNames.set(parseInt(matched[1], 10), name);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const name = genreNames.get(id);
|
||||||
|
if (name) {
|
||||||
|
labels.push(name.charAt(0).toUpperCase() + name.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(pageProps?.tags)) {
|
||||||
|
for (const tag of pageProps.tags) {
|
||||||
|
const title = tag?.title && String(tag.title).trim();
|
||||||
|
if (title) labels.push(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read genres/tags from __NEXT_DATA__:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags =
|
if (labels.length > 0) {
|
||||||
loadedCheerio('td:contains("Tags")')
|
novel.genres = labels
|
||||||
.next()
|
.filter((label, index) => labels.indexOf(label) === index)
|
||||||
.find('a')
|
.join(', ');
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.replace(/,$/, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray() ||
|
|
||||||
loadedCheerio('.tag')
|
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.replace(/,$/, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray() ||
|
|
||||||
loadedCheerio('.tags .tag')
|
|
||||||
.map((i, el) =>
|
|
||||||
loadedCheerio(el)
|
|
||||||
.text()
|
|
||||||
.replace(/<!--.*?-->/g, '')
|
|
||||||
.replace(/,$/, '')
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
// console.log('Found tags from HTML:', tags);
|
|
||||||
|
|
||||||
if (tags.length > 0) {
|
|
||||||
const existingGenres = novel.genres ? novel.genres.split(', ') : [];
|
|
||||||
// console.log('Existing genres:', existingGenres);
|
|
||||||
const allGenres = [...existingGenres, ...tags].filter(
|
|
||||||
genre => genre && genre.length > 0,
|
|
||||||
);
|
|
||||||
const uniqueGenres = allGenres.filter(
|
|
||||||
(genre, index) => allGenres.indexOf(genre) === index,
|
|
||||||
);
|
|
||||||
novel.genres = uniqueGenres.join(', ');
|
|
||||||
// console.log('Combined genres:', novel.genres);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!novel.author) {
|
if (!novel.author) {
|
||||||
@@ -408,7 +546,7 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
combined = new Uint8Array(ciphertext.length + tag.length);
|
combined = new Uint8Array(ciphertext.length + tag.length);
|
||||||
|
|
||||||
// Make the ciphertext + tag format expected for decryption
|
// Make the ciphertext + tag format expected for decryption
|
||||||
combined.set(ciphertext), combined.set(tag, ciphertext.length);
|
(combined.set(ciphertext), combined.set(tag, ciphertext.length));
|
||||||
|
|
||||||
// Decrypt with encKey
|
// Decrypt with encKey
|
||||||
// Convert the key to bytes (first 32 characters of encKey)
|
// Convert the key to bytes (first 32 characters of encKey)
|
||||||
@@ -539,10 +677,16 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const translationTypes = ['ai', 'web'];
|
const translationTypes = this.translationModes;
|
||||||
|
const cookie = this.sessionCookie;
|
||||||
|
|
||||||
let eLog = '';
|
const attemptLog: string[] = [];
|
||||||
let parsedJson;
|
let parsedJson;
|
||||||
|
let usedType: string | null = null;
|
||||||
|
|
||||||
|
// Establish a session from the emailed link before asking for a chapter.
|
||||||
|
const signInNote = await this.ensureSignedIn();
|
||||||
|
if (signInNote) attemptLog.push(signInNote);
|
||||||
|
|
||||||
for (const type of translationTypes) {
|
for (const type of translationTypes) {
|
||||||
const apiResponse = await fetchApi(`${this.site}api/reader/get`, {
|
const apiResponse = await fetchApi(`${this.site}api/reader/get`, {
|
||||||
@@ -550,6 +694,7 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
},
|
},
|
||||||
referrer: url,
|
referrer: url,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -562,18 +707,64 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
parsedJson = await apiResponse.json();
|
// Read as text first: an auth redirect or a Cloudflare challenge returns
|
||||||
if (!apiResponse.ok) {
|
// HTML, and .json() would throw before we could report what came back.
|
||||||
if (parsedJson.error) {
|
const rawBody = await apiResponse.text();
|
||||||
eLog = parsedJson.error;
|
let candidate = null;
|
||||||
continue;
|
try {
|
||||||
}
|
candidate = JSON.parse(rawBody);
|
||||||
} else if (!parsedJson.error) {
|
} catch (e) {
|
||||||
break;
|
candidate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
attemptLog.push(
|
||||||
|
`"${type}": HTTP ${apiResponse.status} — response was not JSON: ${rawBody
|
||||||
|
.slice(0, 150)
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.trim()}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedJson = candidate;
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
attemptLog.push(
|
||||||
|
`"${type}": HTTP ${apiResponse.status}${
|
||||||
|
candidate.error ? ' — ' + candidate.error : ''
|
||||||
|
}${candidate.message ? ' — ' + candidate.message : ''}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.error) {
|
||||||
|
attemptLog.push(`"${type}": ${candidate.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidate.success === false) {
|
||||||
|
attemptLog.push(
|
||||||
|
`"${type}": ${candidate.message || 'request was not successful'}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedType = type;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (parsedJson.success == false) {
|
|
||||||
const errorMsg = parsedJson.message;
|
const showNotice = storage.get<boolean>('showModeNotice') !== false;
|
||||||
|
const cookieState = showNotice
|
||||||
|
? await this.checkSession()
|
||||||
|
: cookie
|
||||||
|
? `session cookie sent (${cookie.length} chars)`
|
||||||
|
: 'no session cookie set';
|
||||||
|
|
||||||
|
if (!usedType || !parsedJson?.data?.data) {
|
||||||
|
const errorMsg =
|
||||||
|
`None of the requested translations could be loaded [${cookieState}]. ` +
|
||||||
|
(attemptLog.length
|
||||||
|
? attemptLog.join(' | ')
|
||||||
|
: 'The server returned no usable response.');
|
||||||
console.error(errorMsg);
|
console.error(errorMsg);
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
@@ -599,11 +790,17 @@ class WTRLAB implements Plugin.PluginBase {
|
|||||||
return htmlString;
|
return htmlString;
|
||||||
}
|
}
|
||||||
chapterContent = await this.translate(chapterContent);
|
chapterContent = await this.translate(chapterContent);
|
||||||
htmlString += `<p><small>This is being translated from your device via google translate (source's method) - Login via web view to try for ai translations</small></p>`;
|
usedType = `${usedType} + Google Translate (on-device)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eLog !== '') {
|
if (storage.get<boolean>('showModeNotice') !== false && usedType) {
|
||||||
htmlString += `<p style="color:darkred;">${eLog}</p>`;
|
htmlString += `<p><small>Translation: ${usedType} — ${cookieState}</small></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptLog.length) {
|
||||||
|
htmlString += `<p style="color:darkred;"><small>Skipped: ${attemptLog.join(
|
||||||
|
' | ',
|
||||||
|
)}</small></p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dictionary = chapterGlossary?.terms?.map(t => t[0]) || [];
|
const dictionary = chapterGlossary?.terms?.map(t => t[0]) || [];
|
||||||
|
|||||||
Reference in New Issue
Block a user