diff --git a/plugins/english/wtrlab.ts b/plugins/english/wtrlab.ts index f6949d52f..0bb43044c 100644 --- a/plugins/english/wtrlab.ts +++ b/plugins/english/wtrlab.ts @@ -9,7 +9,7 @@ class WTRLAB implements Plugin.PluginBase { id = 'WTRLAB'; name = 'WTR-LAB'; site = 'https://wtr-lab.com/'; - version = '1.2.2'; + version = '1.2.3'; icon = 'src/en/wtrlab/icon.png'; sourceLang = 'en/'; baggage = ''; @@ -62,6 +62,52 @@ class WTRLAB implements Plugin.PluginBase { return (storage.get('sessionCookie') || '').trim(); } + /** + * Resolve the chapter payload. + * + * The reader API used to carry the body inline at data.data. It now returns + * an envelope with a `content_url` pointing at the real payload, whose inner + * shape (body / glossary_data / model) is unchanged. Handle both so the + * plugin works whichever the server returns. + */ + async resolveChapterContent( + parsed: ReaderResponse, + cookie: string, + ): Promise { + const inline = parsed?.data?.data; + if (inline && inline.body !== undefined) return inline; + + const contentUrl = parsed?.content_url; + if (!contentUrl) return null; + + const url = /^https?:\/\//i.test(contentUrl) + ? contentUrl + : this.site.replace(/\/$/, '') + contentUrl; + + // Only ever send the session cookie to the source's own hosts. The server + // controls content_url, and an absolute one could point anywhere. + const hostOf = (value: string) => + (value.match(/^https?:\/\/([^/?#]+)/i)?.[1] || '').toLowerCase(); + const siteHost = hostOf(this.site); + const targetHost = hostOf(url); + const sameSite = + !!siteHost && + (targetHost === siteHost || targetHost.endsWith('.' + siteHost)); + + const res = await fetchApi(url, { + headers: { + 'Accept': 'application/json', + ...(cookie && sameSite ? { Cookie: cookie } : {}), + }, + }); + const text = await res.text(); + try { + return JSON.parse(text)?.data?.data ?? null; + } catch (e) { + return null; + } + } + /** Only attempt the sign-in link once per app run: the links are single-use. */ signInAttempted = false; @@ -682,6 +728,7 @@ class WTRLAB implements Plugin.PluginBase { const attemptLog: string[] = []; let parsedJson; + let content: ChapterContent | null = null; let usedType: string | null = null; // Establish a session from the emailed link before asking for a chapter. @@ -748,6 +795,18 @@ class WTRLAB implements Plugin.PluginBase { continue; } + // Resolve the body here, not after the loop: a healthy envelope does + // not guarantee a payload, and if this mode has none the next one + // (typically Web) should still get its turn. + const resolved = await this.resolveChapterContent(candidate, cookie); + if (!resolved || resolved.body === undefined) { + attemptLog.push( + `"${type}": request succeeded but no chapter content was returned`, + ); + continue; + } + + content = resolved; usedType = type; break; } @@ -759,19 +818,23 @@ class WTRLAB implements Plugin.PluginBase { ? `session cookie sent (${cookie.length} chars)` : 'no session cookie set'; - if (!usedType || !parsedJson?.data?.data) { + if (!usedType || !content || content.body === undefined) { const errorMsg = `None of the requested translations could be loaded [${cookieState}]. ` + (attemptLog.length ? attemptLog.join(' | ') - : 'The server returned no usable response.'); + : usedType + ? 'The server accepted the request but returned no chapter content.' + : 'The server returned no usable response.'); console.error(errorMsg); throw new Error(errorMsg); } - let chapterContent: any = parsedJson.data.data.body; - const chapterTitle: string | undefined = parsedJson?.chapter?.title; + + let chapterContent: any = content.body; + const chapterTitle: string | undefined = + parsedJson?.chapter?.title || content.title; const chapterGlossary: ChapterContent['glossary_data'] | undefined = - parsedJson?.data?.data?.glossary_data; + content.glossary_data; let htmlString = ''; if (chapterTitle) { @@ -1973,6 +2036,13 @@ type ApiChapter = { type ChapterData = { data: ChapterContent; }; +type ReaderResponse = { + success?: boolean; + content_url?: string; + chapter?: { title?: string; locked?: boolean }; + data?: { data?: ChapterContent }; +}; + type ChapterContent = { title: string; body: string;