Make a poc for the api for line today & made some docs for myself and

others so others also can use the api
This commit is contained in:
吳元皓 2025-05-20 14:49:50 +08:00
parent 1680945186
commit ba1b3afa6f
12 changed files with 212 additions and 9 deletions

View File

@ -0,0 +1,68 @@
# Scraping line today home
This took me some time, but they use a fancy system for pulling news data.
## Main endpoint
For local Taiwan news they use this url: https://today.line.me/_next/data/v1/tw/v3/tab/domestic.json?tabs=domestic
From the _next? I thought that is static? I mean it maybe is, it is just providing with the URLs that the client will be fetching to the server, which is a bit fun.
Here is a JSON snippet:
```json
{
"id": "682b0cef1b1269f8dec93e60",
"type": "HIGHLIGHT",
"containerStyle": "Header",
"name": "國內話題:新北重大車禍",
"source": "LISTING",
"header": {
"title": "新北重大死傷車禍",
"hasCompositeTitle": false,
"subTitle": "一輛小客車19日下午撞上放學人群造成多名學童、大人送醫至少3死10多傷肇事的78歲男子當場昏迷。"
},
"listings": [
{
"id": "1feef7d2-3acc-495d-becd-3ef4de6a92ce",
"offset": 0,
"length": 10
}
]
},
```
We can ignore everything else, other than the strange UUID in the json. Well, this is the key we need to fetch their api 🤩
## Fetch news URLs
Here is the fancy URL:
`https://today.line.me/api/v6/listings/{the-uuid-you-got-in-the-listings-json-file}/?country=tw&offset=0&length=24`
This api can be used for fetching the news from them, however, there is an issue, the max length is only just 24 (yes, I tried it only can return 24 when requesting for 1000)
And viewing the JSON, oh would you look at that.
```JSON
{
"id": "262862833",
"title": "派駐芬蘭遭白委扯焦慮症 林昶佐現身喊話",
"publisher": "太報",
"publisherId": "101366",
"publishTimeUnix": 1747670221000,
"contentType": "GENERAL",
"thumbnail": {
"type": "IMAGE",
"hash": "0hvoq7de5NKUBMTjcMHM5WF3QYJTF_KDNJbixudj4ddXJnYm4ecX16Iz0edWwydjsTbH9vdm5IJ3EyKjtBeA"
},
"url": {
"hash": "8nlkYeV"
},
"categoryId": 100262,
"categoryName": "國內",
"shortDescription": "前立委林昶佐右二將出任駐芬蘭代表民眾黨立委林憶君卻質疑罹患焦慮症不適合去北歐。翻攝畫面前立委林昶佐將接任駐芬蘭代表民眾黨立委林憶君今5/19質詢指出林林昶佐曾患焦慮症北歐國家日常短病症容易發作質疑是否適合。林昶佐晚間現身直播節目向病友喊話要對自己有信心「絕對可以回復到正常生活包括工作」。林憶君指出1990年芬蘭是全球自殺率最高國家而且北歐國家的日照很短病症容易發作..."
},
```
The url hash is just what we needed to use my scraper :D
You can query it by using: https://news.yuanhau.com/api/news/get/lt/8nlkYeV (Also videos are in the list, so avoid that) or just try this https://today.line.me/tw/v2/article/8nlkYeV
and that's it, I've bypassed Line's attempt to block people like me. :)

View File

@ -19,6 +19,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"axios": "^1.9.0",
"bootstrap-icons": "^1.12.1", "bootstrap-icons": "^1.12.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -850,6 +851,8 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="],
"b4a": ["b4a@1.6.7", "", {}, "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="], "b4a": ["b4a@1.6.7", "", {}, "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@ -1262,6 +1265,8 @@
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="], "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],

59
components/app/popup.vue Normal file
View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { useThrottleFn } from "@vueuse/core";
const props = defineProps<{
title: string;
}>();
const emit = defineEmits(["close"]);
const title = computed(() => props.title || "Popup Window");
const dragging = ref(false);
const position = ref({
x: Math.floor(window.innerWidth / 2 - parseInt(props.width || "400") / 2),
y: Math.floor(window.innerHeight / 2 - parseInt(props.height || "200") / 2),
});
const offset = ref({ x: 0, y: 0 });
const doDrag = useThrottleFn((e: MouseEvent) => {
if (!dragging.value) return;
requestAnimationFrame(() => {
position.value = {
x: Math.max(
0,
Math.min(window.innerWidth - 400, e.clientX - offset.value.x),
),
y: Math.max(
0,
Math.min(window.innerHeight - 300, e.clientY - offset.value.y),
),
};
});
}, 16);
const startDrag = (e: MouseEvent) => {
dragging.value = true;
offset.value = {
x: e.clientX - position.value.x,
y: e.clientY - position.value.y,
};
document.addEventListener("mousemove", doDrag);
document.addEventListener("mouseup", stopDrag);
};
const stopDrag = () => {
dragging.value = false;
document.removeEventListener("mousemove", doDrag);
document.removeEventListener("mouseup", stopDrag);
};
</script>
<template>
<div class="flex flex-col rounded-xl gray-500/80 backdrop-blur-sm">
<div
class="flex flex-row absolute inset-x-0 top-0 h-8 bg-gray-600/80"
></div>
<div class="">Yo a popup!</div>
<button @click="">OK!</button>
</div>
</template>

View File

@ -79,7 +79,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<blurPageBeforeLogin> <blurPageBeforeLogin>
<div class="flex flex-col h-full"> <div class="flex flex-col h-[100%] w-full">
<div> <div>
<div <div
class="justify-center align-center text-center flex flex-col sticky top-0 pt-2 min-h-0 border rounded-2xl gray-500/80 backdrop-blur-sm" class="justify-center align-center text-center flex flex-col sticky top-0 pt-2 min-h-0 border rounded-2xl gray-500/80 backdrop-blur-sm"
@ -110,7 +110,7 @@ onMounted(async () => {
> >
<div <div
v-for="message in messages" v-for="message in messages"
class="max-w-[80%] rounded-lg p-3 bg-gray-300/70 rounded-xl" class="max-w-[80%] p-3 bg-gray-300/70 rounded-xl"
> >
<div v-if="message.user" class="flex flex-row gap-2"> <div v-if="message.user" class="flex flex-row gap-2">
<User class="w-5 h-5" />{{ message.message }} <User class="w-5 h-5" />{{ message.message }}
@ -120,8 +120,9 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="h-[75px]"></div>
<div <div
class="space-x-2 sticky bottom-0 border-t p-2 min-h-0 border rounded-xl gray-500/80 backdrop-blur-sm" class="space-x-2 absolute bottom-2 inset-x-4 border-t p-2 min-h-0 border rounded-xl gray-500/80 backdrop-blur-sm"
> >
<div class="text-black w-full flex flex-row"> <div class="text-black w-full flex flex-row">
<Input <Input

View File

@ -5,7 +5,10 @@ const { data: favData, error, pending } = useFetch("/api/user/fav", {});
</script> </script>
<template> <template>
<BlurPageBeforeLogin> <BlurPageBeforeLogin>
<div>{{ favData }}</div> <div>
<!--Testing only!!!--> <div v-for="items in favData.items">
{{ items }}
</div>
</div>
</BlurPageBeforeLogin> </BlurPageBeforeLogin>
</template> </template>

View File

@ -28,6 +28,7 @@
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"axios": "^1.9.0",
"bootstrap-icons": "^1.12.1", "bootstrap-icons": "^1.12.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -471,7 +471,9 @@ watchEffect((cleanupFn) => {
v-else v-else
> >
<!--Menu container--> <!--Menu container-->
<div class="flex flex-row g-2 text-gray-400 z-9999 selection:opacity-0"> <div
class="flex flex-row g-2 rounded-xl gray-500/80 backdrop-blur-sm z-9999 selection:opacity-0"
>
<button <button
@click="toggleMenu" @click="toggleMenu"
class="w-8 h-8 text-white hover:text-blue-500 transition-all duration-100 flex flex-row" class="w-8 h-8 text-white hover:text-blue-500 transition-all duration-100 flex flex-row"

View File

@ -154,7 +154,7 @@ useSeoMeta({
id="cards" id="cards"
> >
<div <div
class="px-10 bg-gray-900/70 w-[300px] h-[200px] group rounded-xl shadow-lg hover:shadow-sky-600/90 backdrop-blur-sm border border-gray-800 hover:border-gray-600/70 transition-all duration-700 justify-center align-middle flex flex-col" class="px-10 bg-gray-900/70 w-[300px] h-[200px] group rounded-xl shadow-lg hover:shadow-sky-600/90 hover:-translate-y-3 backdrop-blur-sm border border-gray-800 hover:border-gray-600/70 transition-all duration-700 justify-center align-middle flex flex-col"
v-for="item in cards" v-for="item in cards"
:key="item.title" :key="item.title"
> >

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
const localePath = useLocalePath(); const localePath = useLocalePath();
// Import Icons // Import Icons
import { SearchXIcon } from "lucide-vue-next"; import { SearchXIcon, CircleSlash2Icon } from "lucide-vue-next";
// Array // Array
const tools = [ const tools = [
{ {
@ -10,6 +10,12 @@ const tools = [
icon: SearchXIcon, icon: SearchXIcon,
go: localePath("/tools/checkweirdkeywords"), go: localePath("/tools/checkweirdkeywords"),
}, },
{
name: "無廣告新聞",
content: "提供無廣告的LINE Today 新聞",
icon: CircleSlash2Icon,
go: localePath("/tools/freelinetoday"),
},
]; ];
</script> </script>
<template> <template>
@ -22,7 +28,7 @@ const tools = [
> >
<NuxtLink :to="item.go" v-for="item in tools"> <NuxtLink :to="item.go" v-for="item in tools">
<div <div
class="px-10 bg-gray-900/70 w-[300px] h-[200px] group rounded-xl shadow-lg hover:shadow-sky-700/90 backdrop-blur-sm border border-gray-800 hover:border-gray-600/70 transition-all duration-700 justify-center align-middle flex flex-col" class="px-10 bg-gray-900/70 w-[300px] h-[200px] group rounded-xl shadow-lg hover:shadow-sky-700/90 hover:-translate-y-3 backdrop-blur-sm border border-gray-800 hover:border-gray-600/70 transition-all duration-700 justify-center align-middle flex flex-col"
> >
<component <component
:is="item.icon" :is="item.icon"

View File

View File

@ -0,0 +1,23 @@
// Check /about/scraping_line_today_home.md for more info or https://news.yuanhau.com/datainfo/linetodayjsondata.json
async function getLineTodayData(type: string) {
try {
const buildUrl = `https://today.line.me/_next/data/v1/tw/v3/tab/${type}.json?tabs=${type}`;
const req = await fetch(buildUrl, {
headers: {
"Accept-Encoding": "gzip, deflate, br",
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
const res = await req.json();
const data = res.getPageData?.[type];
return res;
} catch (e) {
console.log(e);
}
}
console.log(await getLineTodayData("domestic"));
//export default getLineTodayData;

35
server/fetchapi/run.txt Normal file
View File

@ -0,0 +1,35 @@
{
pageProps: {
__lang: "tw",
__namespaces: {
common: [Object ...],
forYou: [Object ...],
video: [Object ...],
quiz: [Object ...],
poll: [Object ...],
},
runtimeConfig: {
countryConfig: [Object ...],
COMMIT_SHA: "fb546888",
DEPLOY_ENV: "release",
SENTRY_DSN: "https://8ca685e4ca2f4b2ba32b4459381f91b8@sentry-uit.line-apps.com/475",
},
_sentryTraceData: "5c81d62ef22d861e5555ee0e170bd82b-a6d22f47579e751a-1",
_sentryBaggage: "sentry-environment=release,sentry-release=release%40fb546888,sentry-public_key=8ca685e4ca2f4b2ba32b4459381f91b8,sentry-trace_id=5c81d62ef22d861e5555ee0e170bd82b,sentry-transaction=getServerSideProps%20%2Fv3%2Ftab%2F%5B%5B...tabs%5D%5D,sentry-sampled=true",
articleDedupeState: {
pool: [Object ...],
serverPool: [Object ...],
},
fallback: {
getTabsData: [Object ...],
"getPageData,domestic": [Object ...],
"5e4ceeda408f3c5ca031940d_{\"offset\":5,\"length\":3}": [
[Object ...], [Object ...], [Object ...]
],
"5e4cee49408f3c5ca031940b_{\"offset\":0,\"length\":5}": [
[Object ...], [Object ...], [Object ...], [Object ...], [Object ...]
],
},
},
__N_SSP: true,
}