Text to Speech (#2)
* Initial TTS scrapping implemented * Audio & copy buttons added * TTS langs mapping fix * Webkit audio api fix * Added TTS-related testing * Last tweaks
This commit is contained in:
@@ -1,24 +1,65 @@
|
|||||||
import { FC, ChangeEvent } from "react";
|
import { FC, ChangeEvent } from "react";
|
||||||
import { Textarea, useBreakpointValue } from "@chakra-ui/react";
|
import { Box, HStack, Textarea, IconButton, Tooltip, useBreakpointValue, useClipboard } from "@chakra-ui/react";
|
||||||
|
import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
|
||||||
|
import { useAudioFromBuffer } from "../hooks";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string,
|
value: string,
|
||||||
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void,
|
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void,
|
||||||
readOnly?: true
|
readOnly?: true,
|
||||||
|
audio?: number[],
|
||||||
|
canCopy?: boolean,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
};
|
};
|
||||||
|
|
||||||
const TranslationArea: FC<Props> = ({ value, onChange, readOnly, ...props }) => (
|
const TranslationArea: FC<Props> = ({ value, onChange, readOnly, audio, canCopy, ...props }) => {
|
||||||
<Textarea
|
const { hasCopied, onCopy } = useClipboard(value);
|
||||||
value={value}
|
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
||||||
onChange={onChange}
|
return (
|
||||||
readOnly={readOnly}
|
<Box
|
||||||
dir="auto"
|
position="relative"
|
||||||
resize="none"
|
w="full"
|
||||||
rows={useBreakpointValue([6, null, 12]) ?? undefined}
|
>
|
||||||
size="lg"
|
<Textarea
|
||||||
{...props}
|
value={value}
|
||||||
/>
|
onChange={onChange}
|
||||||
);
|
readOnly={readOnly}
|
||||||
|
dir="auto"
|
||||||
|
resize="none"
|
||||||
|
rows={useBreakpointValue([6, null, 12]) ?? undefined}
|
||||||
|
size="lg"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<HStack
|
||||||
|
position="absolute"
|
||||||
|
bottom={4}
|
||||||
|
right={4}
|
||||||
|
>
|
||||||
|
{canCopy && (
|
||||||
|
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
icon={hasCopied ? <FaCheck /> : <FaCopy />}
|
||||||
|
onClick={onCopy}
|
||||||
|
colorScheme="lingva"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!value}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
|
||||||
|
<IconButton
|
||||||
|
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
|
||||||
|
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
|
||||||
|
onClick={onAudioClick}
|
||||||
|
colorScheme="lingva"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!audioExists}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TranslationArea;
|
export default TranslationArea;
|
||||||
|
|||||||
@@ -58,14 +58,7 @@ it("switches first loaded page and back and forth on language change", () => {
|
|||||||
.should("include", `/auto/en/${encodeURIComponent(query)}`);
|
.should("include", `/auto/en/${encodeURIComponent(query)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't switch initial page on language change", () => {
|
it("language switching button is disabled on 'auto', but enables when other", () => {
|
||||||
cy.findByRole("combobox", { name: /source language/i })
|
|
||||||
.select("eo")
|
|
||||||
.url()
|
|
||||||
.should("not.include", "/eo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("language switching button is disable on 'auto', but enables when other", () => {
|
|
||||||
cy.findByRole("button", { name: /switch languages/i })
|
cy.findByRole("button", { name: /switch languages/i })
|
||||||
.as("btnSwitch")
|
.as("btnSwitch")
|
||||||
.should("be.disabled");
|
.should("be.disabled");
|
||||||
@@ -78,10 +71,38 @@ it("language switching button is disable on 'auto', but enables when other", ()
|
|||||||
cy.findByRole("combobox", { name: /target language/i })
|
cy.findByRole("combobox", { name: /target language/i })
|
||||||
.should("have.value", "eo")
|
.should("have.value", "eo")
|
||||||
.get("@source")
|
.get("@source")
|
||||||
.should("have.value", "en");
|
.should("have.value", "en")
|
||||||
|
.url()
|
||||||
|
.should("not.include", "/en")
|
||||||
|
.should("not.include", "/eo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles color mode on button click", () => {
|
it("loads & plays audio correctly", () => {
|
||||||
|
const query = faker.lorem.words(5);
|
||||||
|
cy.visit(`/la/en/${query}`);
|
||||||
|
|
||||||
|
const play = "Play audio";
|
||||||
|
const stop = "Stop audio";
|
||||||
|
|
||||||
|
cy.findAllByRole("button", { name: play })
|
||||||
|
.should("be.enabled")
|
||||||
|
.click({ multiple: true })
|
||||||
|
.should("have.attr", "aria-label", stop)
|
||||||
|
.click({ multiple: true })
|
||||||
|
.should("have.attr", "aria-label", play)
|
||||||
|
.click({ multiple: true })
|
||||||
|
.should("have.attr", "aria-label", play)
|
||||||
|
.click({ multiple: true })
|
||||||
|
.should("have.attr", "aria-label", stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips to main & toggles color mode", () => {
|
||||||
|
cy.findByRole("link", { name: /skip to content/i })
|
||||||
|
.focus()
|
||||||
|
.click()
|
||||||
|
.url()
|
||||||
|
.should("include", "#main");
|
||||||
|
|
||||||
const white = "rgb(255, 255, 255)";
|
const white = "rgb(255, 255, 255)";
|
||||||
cy.get("body")
|
cy.get("body")
|
||||||
.should("have.css", "background-color", white);
|
.should("have.css", "background-color", white);
|
||||||
@@ -95,11 +116,3 @@ it("toggles color mode on button click", () => {
|
|||||||
.get("body")
|
.get("body")
|
||||||
.should("have.css", "background-color", white);
|
.should("have.css", "background-color", white);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips to main on 'skip link' click", () => {
|
|
||||||
cy.findByRole("link", { name: /skip to content/i })
|
|
||||||
.focus()
|
|
||||||
.click()
|
|
||||||
.url()
|
|
||||||
.should("include", "#main");
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as useToastOnLoad } from "./useToastOnLoad";
|
export { default as useToastOnLoad } from "./useToastOnLoad";
|
||||||
|
export { default as useAudioFromBuffer } from "./useAudioFromBuffer";
|
||||||
|
|||||||
60
hooks/useAudioFromBuffer.ts
Normal file
60
hooks/useAudioFromBuffer.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webkitAudioContext: typeof AudioContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAudioFromBuffer = (bufferArray?: number[]) => {
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const [audioSource, setAudioSource] = useState<AudioBufferSourceNode | null>(null);
|
||||||
|
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioCtx)
|
||||||
|
return;
|
||||||
|
audioCtxRef.current = new AudioCtx();
|
||||||
|
return () => {
|
||||||
|
audioCtxRef.current?.close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bufferArray)
|
||||||
|
return setAudioBuffer(null);
|
||||||
|
|
||||||
|
audioCtxRef.current?.decodeAudioData(
|
||||||
|
new Uint8Array(bufferArray).buffer
|
||||||
|
).then(setAudioBuffer);
|
||||||
|
}, [bufferArray]);
|
||||||
|
|
||||||
|
const onAudioClick = () => {
|
||||||
|
if (!audioCtxRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!audioSource) {
|
||||||
|
const source = audioCtxRef.current.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioCtxRef.current.destination);
|
||||||
|
source.start();
|
||||||
|
source.onended = () => {
|
||||||
|
setAudioSource(null);
|
||||||
|
}
|
||||||
|
setAudioSource(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioSource.stop();
|
||||||
|
audioSource.disconnect(audioCtxRef.current.destination);
|
||||||
|
setAudioSource(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioExists: !!audioBuffer,
|
||||||
|
isAudioPlaying: !!audioSource,
|
||||||
|
onAudioClick
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAudioFromBuffer;
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"next-pwa": "^5.0.6",
|
"next-pwa": "^5.0.6",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"react-icons": "^4.2.0"
|
"react-icons": "^4.2.0",
|
||||||
|
"user-agents": "^1.0.597"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/cypress": "^7.0.4",
|
"@testing-library/cypress": "^7.0.4",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/node": "^14.14.33",
|
"@types/node": "^14.14.33",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react": "^17.0.3",
|
||||||
|
"@types/user-agents": "^1.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.0.0",
|
"@typescript-eslint/eslint-plugin": "^4.0.0",
|
||||||
"@typescript-eslint/parser": "^4.0.0",
|
"@typescript-eslint/parser": "^4.0.0",
|
||||||
"babel-eslint": "^10.0.0",
|
"babel-eslint": "^10.0.0",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react";
|
|||||||
import { FaExchangeAlt } from "react-icons/fa";
|
import { FaExchangeAlt } from "react-icons/fa";
|
||||||
import { CustomError, Layout, LangSelect, TranslationArea } from "../components";
|
import { CustomError, Layout, LangSelect, TranslationArea } from "../components";
|
||||||
import { useToastOnLoad } from "../hooks";
|
import { useToastOnLoad } from "../hooks";
|
||||||
import { googleScrape, extractSlug } from "../utils/translate";
|
import { googleScrape, extractSlug, textToSpeechScrape } from "../utils/translate";
|
||||||
import { retrieveFiltered, replaceBoth } from "../utils/language";
|
import { retrieveFiltered, replaceBoth } from "../utils/language";
|
||||||
import langReducer, { Actions, initialState } from "../utils/reducer";
|
import langReducer, { Actions, initialState } from "../utils/reducer";
|
||||||
|
|
||||||
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, statusCode, errorMsg, initial }) => {
|
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, statusCode, errorMsg, initial }) => {
|
||||||
const [{ source, target, query, delayedQuery, translation }, dispatch] = useReducer(langReducer, initialState);
|
const [{ source, target, query, delayedQuery, translation }, dispatch] = useReducer(langReducer, initialState);
|
||||||
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
@@ -92,6 +92,7 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
lang={queryLang}
|
lang={queryLang}
|
||||||
|
audio={audio?.source}
|
||||||
/>
|
/>
|
||||||
<TranslationArea
|
<TranslationArea
|
||||||
id="translation"
|
id="translation"
|
||||||
@@ -100,6 +101,8 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
value={translation ?? ""}
|
value={translation ?? ""}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
lang={transLang}
|
lang={transLang}
|
||||||
|
audio={audio?.target}
|
||||||
|
canCopy={true}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -139,16 +142,25 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrapeRes = await googleScrape(source, target, query);
|
const textScrape = await googleScrape(source, target, query);
|
||||||
|
|
||||||
|
const [sourceAudio, targetAudio] = await Promise.all([
|
||||||
|
textToSpeechScrape(source, query),
|
||||||
|
textToSpeechScrape(target, textScrape.translationRes)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
...scrapeRes,
|
...textScrape,
|
||||||
|
audio: {
|
||||||
|
source: sourceAudio,
|
||||||
|
target: targetAudio
|
||||||
|
},
|
||||||
initial: {
|
initial: {
|
||||||
source, target, query
|
source, target, query
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
revalidate: !scrapeRes.errorMsg && !scrapeRes.statusCode
|
revalidate: !textScrape.errorMsg && !textScrape.statusCode
|
||||||
? 2 * 30 * 24 * 60 * 60 // 2 months
|
? 2 * 30 * 24 * 60 * 60 // 2 months
|
||||||
: 1
|
: 1
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("getStaticProps", () => {
|
|||||||
expect(await getStaticProps({ params: { slug } })).toMatchObject({ redirect: expect.any(Object) });
|
expect(await getStaticProps({ params: { slug } })).toMatchObject({ redirect: expect.any(Object) });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns translation & initial values on 3 params", async () => {
|
it("returns translation, audio & initial values on 3 params", async () => {
|
||||||
const translationRes = faker.random.words();
|
const translationRes = faker.random.words();
|
||||||
resolveFetchWith(htmlRes(translationRes));
|
resolveFetchWith(htmlRes(translationRes));
|
||||||
|
|
||||||
@@ -44,6 +44,10 @@ describe("getStaticProps", () => {
|
|||||||
expect(await getStaticProps({ params: { slug } })).toStrictEqual({
|
expect(await getStaticProps({ params: { slug } })).toStrictEqual({
|
||||||
props: {
|
props: {
|
||||||
translationRes,
|
translationRes,
|
||||||
|
audio: {
|
||||||
|
source: expect.any(Array),
|
||||||
|
target: expect.any(Array)
|
||||||
|
},
|
||||||
initial: {
|
initial: {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
@@ -56,6 +60,13 @@ describe("getStaticProps", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
|
const translationRes = faker.random.words();
|
||||||
|
const randomAudio = Array.from({ length: 10 }, () => faker.random.number(100));
|
||||||
|
const audio = {
|
||||||
|
source: randomAudio,
|
||||||
|
target: randomAudio
|
||||||
|
};
|
||||||
|
|
||||||
it("loads the layout correctly", async () => {
|
it("loads the layout correctly", async () => {
|
||||||
render(<Page home={true} />);
|
render(<Page home={true} />);
|
||||||
|
|
||||||
@@ -88,8 +99,7 @@ describe("Page", () => {
|
|||||||
target: "es",
|
target: "es",
|
||||||
query: faker.random.words()
|
query: faker.random.words()
|
||||||
};
|
};
|
||||||
const translationRes = faker.random.words();
|
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
|
||||||
render(<Page translationRes={translationRes} initial={initial} />);
|
|
||||||
|
|
||||||
const source = screen.getByRole("combobox", { name: /source language/i });
|
const source = screen.getByRole("combobox", { name: /source language/i });
|
||||||
expect(source).toHaveValue(initial.source);
|
expect(source).toHaveValue(initial.source);
|
||||||
@@ -107,8 +117,7 @@ describe("Page", () => {
|
|||||||
target: "en",
|
target: "en",
|
||||||
query: faker.random.words()
|
query: faker.random.words()
|
||||||
};
|
};
|
||||||
const translationRes = faker.random.words();
|
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
|
||||||
render(<Page translationRes={translationRes} initial={initial} />);
|
|
||||||
|
|
||||||
const source = screen.getByRole("combobox", { name: /source language/i });
|
const source = screen.getByRole("combobox", { name: /source language/i });
|
||||||
|
|
||||||
@@ -137,8 +146,7 @@ describe("Page", () => {
|
|||||||
target: "ca",
|
target: "ca",
|
||||||
query: faker.random.words()
|
query: faker.random.words()
|
||||||
};
|
};
|
||||||
const translationRes = faker.random.words();
|
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
|
||||||
render(<Page translationRes={translationRes} initial={initial} />);
|
|
||||||
|
|
||||||
const btnSwitch = screen.getByRole("button", { name: /switch languages/i });
|
const btnSwitch = screen.getByRole("button", { name: /switch languages/i });
|
||||||
userEvent.click(btnSwitch);
|
userEvent.click(btnSwitch);
|
||||||
@@ -151,6 +159,20 @@ describe("Page", () => {
|
|||||||
await waitFor(() => expect(Router.push).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(Router.push).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads audio & clipboard correctly", async () => {
|
||||||
|
const initial = {
|
||||||
|
source: "eo",
|
||||||
|
target: "zh",
|
||||||
|
query: faker.random.words()
|
||||||
|
};
|
||||||
|
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
|
||||||
|
|
||||||
|
const btnsAudio = screen.getAllByRole("button", { name: /play audio/i });
|
||||||
|
btnsAudio.forEach(btn => expect(btn).toBeVisible());
|
||||||
|
const btnCopy = screen.getByRole("button", { name: /copy to clipboard/i });
|
||||||
|
expect(btnCopy).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders error page on status code", async () => {
|
it("renders error page on status code", async () => {
|
||||||
const code = faker.random.number({ min: 400, max: 599 });
|
const code = faker.random.number({ min: 400, max: 599 });
|
||||||
render(<Page statusCode={code} />);
|
render(<Page statusCode={code} />);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { htmlRes, resolveFetchWith } from "../commonUtils";
|
import { htmlRes, resolveFetchWith } from "../commonUtils";
|
||||||
import faker from "faker";
|
import faker from "faker";
|
||||||
import { googleScrape, extractSlug } from "../../utils/translate";
|
import { googleScrape, extractSlug, textToSpeechScrape } from "../../utils/translate";
|
||||||
|
|
||||||
const source = faker.random.locale();
|
const source = faker.random.locale();
|
||||||
const target = faker.random.locale();
|
const target = faker.random.locale();
|
||||||
@@ -65,3 +65,21 @@ describe("extractSlug", () => {
|
|||||||
expect(extractSlug(array)).toStrictEqual({});
|
expect(extractSlug(array)).toStrictEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("textToSpeechScrape", () => {
|
||||||
|
it("returns an array on successful request", async () => {
|
||||||
|
resolveFetchWith({ status: 200 });
|
||||||
|
expect(await textToSpeechScrape(target, query)).toEqual(expect.any(Array));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'null' on request error", async () => {
|
||||||
|
const status = faker.random.number({ min: 400, max: 499 });
|
||||||
|
resolveFetchWith({ status });
|
||||||
|
expect(await textToSpeechScrape(target, query)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'null' on network error", async () => {
|
||||||
|
fetchMock.mockRejectOnce();
|
||||||
|
expect(await textToSpeechScrape(target, query)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,7 +123,8 @@
|
|||||||
"source": {},
|
"source": {},
|
||||||
"target": {
|
"target": {
|
||||||
"zh": "zh-CN",
|
"zh": "zh-CN",
|
||||||
"zh_HANT": "zh-TW"
|
"zh_HANT": "zh-TW",
|
||||||
|
"auto": "en"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import UserAgent from "user-agents";
|
||||||
import cheerio from "cheerio";
|
import cheerio from "cheerio";
|
||||||
import { replaceBoth } from "./language";
|
import { replaceBoth } from "./language";
|
||||||
|
|
||||||
@@ -12,8 +13,15 @@ export async function googleScrape(
|
|||||||
}> {
|
}> {
|
||||||
const parsed = replaceBoth("mapping", { source, target });
|
const parsed = replaceBoth("mapping", { source, target });
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://translate.google.com/m?sl=${parsed.source}&tl=${parsed.target}&q=${encodeURIComponent(query)}`
|
`https://translate.google.com/m?sl=${parsed.source}&tl=${parsed.target}&q=${encodeURIComponent(query)}`,
|
||||||
).catch(() => null);
|
{
|
||||||
|
headers: {
|
||||||
|
"User-Agent": new UserAgent().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
if (!res)
|
if (!res)
|
||||||
return {
|
return {
|
||||||
@@ -53,3 +61,29 @@ export function extractSlug(slug: string[]): {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function textToSpeechScrape(lang: string, text?: string) {
|
||||||
|
if (!text)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang });
|
||||||
|
|
||||||
|
const lastSpace = text.lastIndexOf(" ", 200);
|
||||||
|
const slicedText = text.slice(0, text.length > 200 && lastSpace !== -1 ? lastSpace : 200);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`http://translate.google.com/translate_tts?tl=${parsedLang}&q=${encodeURIComponent(slicedText)}&textlen=${slicedText.length}&client=tw-ob`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"User-Agent": new UserAgent().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
return res?.ok
|
||||||
|
? res.blob().then(blob => blob.arrayBuffer()).then(buffer => Array.from(new Uint8Array(buffer)))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
49
yarn.lock
49
yarn.lock
@@ -2266,6 +2266,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
source-map "^0.6.1"
|
source-map "^0.6.1"
|
||||||
|
|
||||||
|
"@types/user-agents@^1.0.0":
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/user-agents/-/user-agents-1.0.0.tgz#aeb7546f50cea28358a019a70ab3ce8827ecf937"
|
||||||
|
integrity sha512-oOSdQ9CULdFN2SJ9NNHvPrkP9aJ6oAz7BiiAeMC4vca+AzFqktlGdetMjlbCA0J3AemMn7ToU3U74VgrMecHtA==
|
||||||
|
|
||||||
"@types/warning@^3.0.0":
|
"@types/warning@^3.0.0":
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
|
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
|
||||||
@@ -3875,6 +3880,11 @@ des.js@^1.0.0:
|
|||||||
inherits "^2.0.1"
|
inherits "^2.0.1"
|
||||||
minimalistic-assert "^1.0.0"
|
minimalistic-assert "^1.0.0"
|
||||||
|
|
||||||
|
detect-indent@~6.0.0:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd"
|
||||||
|
integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==
|
||||||
|
|
||||||
detect-newline@^3.0.0:
|
detect-newline@^3.0.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
|
||||||
@@ -3906,6 +3916,11 @@ dir-glob@^3.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
path-type "^4.0.0"
|
path-type "^4.0.0"
|
||||||
|
|
||||||
|
docopt@~0.6.2:
|
||||||
|
version "0.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/docopt/-/docopt-0.6.2.tgz#b28e9e2220da5ec49f7ea5bb24a47787405eeb11"
|
||||||
|
integrity sha1-so6eIiDaXsSffqW7JKR3h0Be6xE=
|
||||||
|
|
||||||
doctrine@1.5.0:
|
doctrine@1.5.0:
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
|
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
|
||||||
@@ -3975,6 +3990,15 @@ domutils@^2.4.3, domutils@^2.4.4:
|
|||||||
domelementtype "^2.0.1"
|
domelementtype "^2.0.1"
|
||||||
domhandler "^4.0.0"
|
domhandler "^4.0.0"
|
||||||
|
|
||||||
|
dot-json@^1.2.2:
|
||||||
|
version "1.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/dot-json/-/dot-json-1.2.2.tgz#7d35abece4aa22aa75a761388953f98495401bcc"
|
||||||
|
integrity sha512-AKL+GsO4wSEU4LU+fAk/PqN4nQ6PB1vT3HpMiZous9xCzK5S0kh4DzfUY0EfU67jsIXLlu0ty71659N9Nmg+Tw==
|
||||||
|
dependencies:
|
||||||
|
detect-indent "~6.0.0"
|
||||||
|
docopt "~0.6.2"
|
||||||
|
underscore-keypath "~0.0.22"
|
||||||
|
|
||||||
ecc-jsbn@~0.1.1:
|
ecc-jsbn@~0.1.1:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||||
@@ -6221,6 +6245,11 @@ locate-path@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate "^4.1.0"
|
p-locate "^4.1.0"
|
||||||
|
|
||||||
|
lodash.clonedeep@^4.5.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||||
|
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
||||||
|
|
||||||
lodash.debounce@^4.0.8:
|
lodash.debounce@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
@@ -8633,6 +8662,18 @@ unbox-primitive@^1.0.0:
|
|||||||
has-symbols "^1.0.0"
|
has-symbols "^1.0.0"
|
||||||
which-boxed-primitive "^1.0.1"
|
which-boxed-primitive "^1.0.1"
|
||||||
|
|
||||||
|
underscore-keypath@~0.0.22:
|
||||||
|
version "0.0.22"
|
||||||
|
resolved "https://registry.yarnpkg.com/underscore-keypath/-/underscore-keypath-0.0.22.tgz#48a528392bb6efc424be1caa56da4b5faccf264d"
|
||||||
|
integrity sha1-SKUoOSu278QkvhyqVtpLX6zPJk0=
|
||||||
|
dependencies:
|
||||||
|
underscore "*"
|
||||||
|
|
||||||
|
underscore@*:
|
||||||
|
version "1.12.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
|
||||||
|
integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==
|
||||||
|
|
||||||
unicode-canonical-property-names-ecmascript@^1.0.4:
|
unicode-canonical-property-names-ecmascript@^1.0.4:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
|
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
|
||||||
@@ -8751,6 +8792,14 @@ use@^3.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
||||||
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
|
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
|
||||||
|
|
||||||
|
user-agents@^1.0.597:
|
||||||
|
version "1.0.597"
|
||||||
|
resolved "https://registry.yarnpkg.com/user-agents/-/user-agents-1.0.597.tgz#a61659191d99d6de303855dda78cbbded22e5355"
|
||||||
|
integrity sha512-BwG3FDnNEg9+LBaej6cUJlvL3ztk9+8SCiIYLkm9TXR+QgIH5CcRs4tPVz9CdV3niArmOa5sDCM69iv8bUnkZA==
|
||||||
|
dependencies:
|
||||||
|
dot-json "^1.2.2"
|
||||||
|
lodash.clonedeep "^4.5.0"
|
||||||
|
|
||||||
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
|
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
|
|||||||
Reference in New Issue
Block a user