mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-18 23:11:12 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/instagram-scraper
This commit is contained in:
commit
e38ca19b18
119 changed files with 5948 additions and 36 deletions
559
surfsense_backend/tests/fixtures/tiktok/listing_item.json
vendored
Normal file
559
surfsense_backend/tests/fixtures/tiktok/listing_item.json
vendored
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
{
|
||||
"AIGCDescription": "",
|
||||
"CategoryType": 111,
|
||||
"IsHDBitrate": false,
|
||||
"anchors": [
|
||||
{
|
||||
"description": "CapCut · Video Editor",
|
||||
"extraInfo": {
|
||||
"subtype": ""
|
||||
},
|
||||
"icon": {
|
||||
"urlList": [
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=44xwWHkyYJdE%2FIwqS3zAAjU4XQ4%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=lMwgk8e0wLaM3WkpnsMqYcxZN6w%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.jpeg?dr=9627&x-expires=1783544400&x-signature=1EMaRlc0Snbs9Ltwr0s95%2BDvpFk%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5"
|
||||
]
|
||||
},
|
||||
"id": "0",
|
||||
"keyword": "CapCut · Editing made easy",
|
||||
"logExtra": "{\"anchor_id\":0,\"anchor_name\":\"CapCut · Editing made easy\",\"anchor_title_detail\":\"None\",\"anchor_title_id\":21502485763,\"anchor_type\":\"TT_CAPCUT\",\"capability_key\":\"default\",\"ccep_vip\":0,\"if_race_trigger\":0,\"is_two_line\":0,\"landing_page_style_id\":21502486275,\"maker_source\":\"\",\"publish_key\":\"default\",\"template_id\":\"none\",\"tutorial_id\":\"none\",\"viamaker_anchor_capability_key_weight\":1,\"viamaker_anchor_style_idx\":-1,\"viamaker_anchor_style_source\":3,\"video_source\":0,\"video_type_id\":1}",
|
||||
"schema": "https://www.capcut.com/editor?scenario=tiktok&utm_source=tiktok_anchor_tool&utm_medium=tiktok_anchor&utm_campaign=70361230",
|
||||
"thumbnail": {
|
||||
"height": 64,
|
||||
"urlList": [
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=GOZUldk%2BrxINygKT0jwxeJz9VLQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=SJ6q86bjtCoRArlVNS1DjDDFWoc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.jpeg?dr=9627&x-expires=1783544400&x-signature=c7mUcXdyEOFc0SMcLAkkib3v9RU%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5"
|
||||
],
|
||||
"width": 64
|
||||
},
|
||||
"type": 54
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"avatarLarger": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=9640&refresh_token=c6d77c8a&x-expires=1783695600&x-signature=mUTuvDWLEGrAxXtWZlBE7gUxCl8%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"avatarMedium": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:720:720.jpeg?dr=9640&refresh_token=dfe18cb8&x-expires=1783695600&x-signature=cP14vkr0ndxwhW%2B1pWfefXfTsls%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"avatarThumb": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:100:100.jpeg?dr=9640&refresh_token=8c557171&x-expires=1783695600&x-signature=VaAysM3zFkbl%2FOT74d0h2kdkLqo%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"commentSetting": 0,
|
||||
"downloadSetting": 3,
|
||||
"duetSetting": 0,
|
||||
"ftc": false,
|
||||
"id": "7501138183701103662",
|
||||
"isADVirtual": false,
|
||||
"isEmbedBanned": false,
|
||||
"nickname": "Cookwithhali",
|
||||
"openFavorite": false,
|
||||
"privateAccount": false,
|
||||
"relation": 0,
|
||||
"secUid": "MS4wLjABAAAAywZ6lCXK3u4zKz8eQxxcjVblLiugz6zMysU98G7dyKVxu6IvMOJ97mpIfpDxUL4q",
|
||||
"secret": false,
|
||||
"shortDramaCreator": {},
|
||||
"signature": "From my kitchen to your screen! 🍞\nFollow for more ✨\nIG: halikit25\n\n📩 Halikitchen25@gmail.com",
|
||||
"stitchSetting": 0,
|
||||
"uniqueId": "cookwithhali",
|
||||
"verified": false
|
||||
},
|
||||
"authorStats": {
|
||||
"diggCount": 223,
|
||||
"followerCount": 372600,
|
||||
"followingCount": 0,
|
||||
"friendCount": 0,
|
||||
"heart": 8000000,
|
||||
"heartCount": 8000000,
|
||||
"videoCount": 279
|
||||
},
|
||||
"authorStatsV2": {
|
||||
"diggCount": "223",
|
||||
"followerCount": "372600",
|
||||
"followingCount": "0",
|
||||
"friendCount": "0",
|
||||
"heart": "8000000",
|
||||
"heartCount": "8000000",
|
||||
"videoCount": "279"
|
||||
},
|
||||
"backendSourceEventTracking": "",
|
||||
"challenges": [
|
||||
{
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": "",
|
||||
"desc": "",
|
||||
"id": "6983",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"title": "bread"
|
||||
},
|
||||
{
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": "",
|
||||
"desc": "",
|
||||
"id": "285169",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"title": "garlicbread"
|
||||
},
|
||||
{
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": "",
|
||||
"desc": "",
|
||||
"id": "1643317566954502",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"title": "breadtok"
|
||||
},
|
||||
{
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": "",
|
||||
"desc": "Whether you're baking the perfect pie or just searching for a recipe, you've come to the right place for all things #Baking.",
|
||||
"id": "7250",
|
||||
"profileLarger": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"profileMedium": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"profileThumb": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"title": "baking"
|
||||
},
|
||||
{
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": "",
|
||||
"desc": "",
|
||||
"id": "10488587",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"title": "homemadebread"
|
||||
}
|
||||
],
|
||||
"collected": false,
|
||||
"contents": [
|
||||
{
|
||||
"desc": "Pull Apart Garlic Bread "
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "Ingredients"
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tsp yeast"
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tbsp sugar"
|
||||
},
|
||||
{
|
||||
"desc": "* 270g milk (about 1 cup + 2 tbsp)"
|
||||
},
|
||||
{
|
||||
"desc": "* 380g flour (about 3 cups)"
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tsp salt"
|
||||
},
|
||||
{
|
||||
"desc": "* 43g softened butter (about 3 tbsp)"
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "Butter Mixture"
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "* 100g butter, softened"
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tbsp chopped cilantro"
|
||||
},
|
||||
{
|
||||
"desc": "* 1 garlic clove, minced"
|
||||
},
|
||||
{
|
||||
"desc": "* Shredded cheese"
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "Instructions"
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "1. In a bowl, mix the sugar and yeast. Add the milk, then add the flour and mix until combined."
|
||||
},
|
||||
{
|
||||
"desc": "2. Add the salt and softened butter, then coil fold until the butter is fully incorporated."
|
||||
},
|
||||
{
|
||||
"desc": "3. Cover and let the dough rest for 2 hours or until doubled."
|
||||
},
|
||||
{
|
||||
"desc": "4. Mix the softened butter with the cilantro and minced garlic."
|
||||
},
|
||||
{
|
||||
"desc": "5. Roll the dough into a rectangle and spread the garlic butter mixture evenly over it. Sprinkle with shredded cheddar cheese."
|
||||
},
|
||||
{
|
||||
"desc": "6. Cut the dough in half, stack one half on top of the other, then cut into strips. Transfer the pieces into a loaf pan."
|
||||
},
|
||||
{
|
||||
"desc": "7. Cover and let rest for 20 minutes."
|
||||
},
|
||||
{
|
||||
"desc": "8. Bake at 370°F for 30 minutes, or until golden brown."
|
||||
},
|
||||
{
|
||||
"desc": "9. Brush with the remaining garlic butter mixture while still warm."
|
||||
},
|
||||
{
|
||||
"desc": ""
|
||||
},
|
||||
{
|
||||
"desc": "#bread #garlicbread #breadtok #baking #homemadebread ",
|
||||
"textExtra": [
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 6,
|
||||
"hashtagName": "bread",
|
||||
"isCommerce": false,
|
||||
"start": 0,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 19,
|
||||
"hashtagName": "garlicbread",
|
||||
"isCommerce": false,
|
||||
"start": 7,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 29,
|
||||
"hashtagName": "breadtok",
|
||||
"isCommerce": false,
|
||||
"start": 20,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 37,
|
||||
"hashtagName": "baking",
|
||||
"isCommerce": false,
|
||||
"start": 30,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 52,
|
||||
"hashtagName": "homemadebread",
|
||||
"isCommerce": false,
|
||||
"start": 38,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"createTime": 1782509863,
|
||||
"desc": "Pull Apart Garlic Bread Ingredients * 1 tsp yeast * 1 tbsp sugar * 270g milk (about 1 cup + 2 tbsp) * 380g flour (about 3 cups) * 1 tsp salt * 43g softened butter (about 3 tbsp) Butter Mixture * 100g butter, softened * 1 tbsp chopped cilantro * 1 garlic clove, minced * Shredded cheese Instructions 1. In a bowl, mix the sugar and yeast. Add the milk, then add the flour and mix until combined. 2. Add the salt and softened butter, then coil fold until the butter is fully incorporated. 3. Cover and let the dough rest for 2 hours or until doubled. 4. Mix the softened butter with the cilantro and minced garlic. 5. Roll the dough into a rectangle and spread the garlic butter mixture evenly over it. Sprinkle with shredded cheddar cheese. 6. Cut the dough in half, stack one half on top of the other, then cut into strips. Transfer the pieces into a loaf pan. 7. Cover and let rest for 20 minutes. 8. Bake at 370°F for 30 minutes, or until golden brown. 9. Brush with the remaining garlic butter mixture while still warm. #bread #garlicbread #breadtok #baking #homemadebread ",
|
||||
"digged": false,
|
||||
"diversificationId": 10040,
|
||||
"duetDisplay": 0,
|
||||
"duetEnabled": true,
|
||||
"forFriend": false,
|
||||
"id": "7655821461772406047",
|
||||
"isAd": false,
|
||||
"isReviewing": false,
|
||||
"itemCommentStatus": 0,
|
||||
"item_control": {
|
||||
"can_repost": true
|
||||
},
|
||||
"music": {
|
||||
"authorName": "Cookwithhali",
|
||||
"coverLarge": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=9640&refresh_token=c6d77c8a&x-expires=1783695600&x-signature=mUTuvDWLEGrAxXtWZlBE7gUxCl8%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"coverMedium": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:720:720.jpeg?dr=9640&refresh_token=dfe18cb8&x-expires=1783695600&x-signature=cP14vkr0ndxwhW%2B1pWfefXfTsls%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"coverThumb": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:100:100.jpeg?dr=9640&refresh_token=8c557171&x-expires=1783695600&x-signature=VaAysM3zFkbl%2FOT74d0h2kdkLqo%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"duration": 62,
|
||||
"id": "7655821492566919966",
|
||||
"isCopyrighted": false,
|
||||
"is_commerce_music": true,
|
||||
"is_unlimited_music": false,
|
||||
"original": true,
|
||||
"playUrl": "https://v16m.tiktokcdn-us.com/ecd83422a3e7bf5450bd92ebf07e6fce/6a4ec449/video/tos/useast8/tos-useast8-v-27dcd7-tx2/ocBIhANWILSemeAAINEGoTTPe58AJaRegg6eKS/?a=1233&bti=ODszNWYuMDE6&&bt=125&ft=GSDrKInz7Th2sbSGXq8Zmo&mime_type=audio_mpeg&rc=anY5cXA5cmRpOzMzaTU8NEBpanY5cXA5cmRpOzMzaTU8NEBpYzZrMmRjL3NhLS1kMTJzYSNpYzZrMmRjL3NhLS1kMTJzcw%3D%3D&vvpl=1&l=2026070815413036F7D0D0506FB569C78C&btag=e00050000",
|
||||
"private": false,
|
||||
"shoot_duration": 62,
|
||||
"title": "original sound",
|
||||
"tt2dsp": {}
|
||||
},
|
||||
"officalItem": false,
|
||||
"originalItem": false,
|
||||
"privateItem": false,
|
||||
"secret": false,
|
||||
"shareEnabled": true,
|
||||
"stats": {
|
||||
"collectCount": 396400,
|
||||
"commentCount": 3720,
|
||||
"diggCount": 754100,
|
||||
"playCount": 8300000,
|
||||
"shareCount": 144500
|
||||
},
|
||||
"statsV2": {
|
||||
"collectCount": "396397",
|
||||
"commentCount": "3720",
|
||||
"diggCount": "754100",
|
||||
"playCount": "8300000",
|
||||
"repostCount": "0",
|
||||
"shareCount": "144500"
|
||||
},
|
||||
"stickersOnItem": [
|
||||
{
|
||||
"stickerText": [
|
||||
"Cheesy Pull Apart Garlic Bread! 🧄🧀\n",
|
||||
"(Bakery-Style & No-Knead)"
|
||||
],
|
||||
"stickerType": 4
|
||||
}
|
||||
],
|
||||
"stitchDisplay": 0,
|
||||
"stitchEnabled": true,
|
||||
"textExtra": [
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 1030,
|
||||
"hashtagName": "bread",
|
||||
"isCommerce": false,
|
||||
"start": 1024,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 1043,
|
||||
"hashtagName": "garlicbread",
|
||||
"isCommerce": false,
|
||||
"start": 1031,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 1053,
|
||||
"hashtagName": "breadtok",
|
||||
"isCommerce": false,
|
||||
"start": 1044,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 1061,
|
||||
"hashtagName": "baking",
|
||||
"isCommerce": false,
|
||||
"start": 1054,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"end": 1076,
|
||||
"hashtagName": "homemadebread",
|
||||
"isCommerce": false,
|
||||
"start": 1062,
|
||||
"subType": 0,
|
||||
"type": 1
|
||||
}
|
||||
],
|
||||
"textLanguage": "en",
|
||||
"textTranslatable": true,
|
||||
"video": {
|
||||
"PlayAddrStruct": {
|
||||
"DataSize": 13761060,
|
||||
"FileCs": "c:0-51456-f8d1",
|
||||
"FileHash": "485174c8492db027adbbf6dca7c7c201",
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_720p_1773703",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=aa5be80671c4a73d54517ee8e4a471ad&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=aa5be80671c4a73d54517ee8e4a471ad&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=9de1abcaf21d44d081117ee2ca3d4cc2&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjcwMGExZmI3NDg4YzFiZTIwZjczM2IyMmM0OTk2YTI2&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 720
|
||||
},
|
||||
"VQScore": "69.69",
|
||||
"bitrate": 1773703,
|
||||
"bitrateInfo": [
|
||||
{
|
||||
"Bitrate": 1836180,
|
||||
"BitrateFPS": 29,
|
||||
"CodecType": "h265_hvc1",
|
||||
"Format": "mp4",
|
||||
"GearName": "adapt_lowest_1080_1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 96.83, \"v960\": 97.64, \"v864\": 98.256, \"v720\": 98.838}, \"ori\": {\"v1080\": 91.834, \"v960\": 93.423, \"v864\": 94.54, \"v720\": 95.872}}}",
|
||||
"PlayAddr": {
|
||||
"DataSize": 14245777,
|
||||
"FileCs": "c:0-52754-ed06",
|
||||
"FileHash": "38996a98cfeb2714fca1b94768090c37",
|
||||
"Height": 1920,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_1080p_1836180",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oIygxEfEVAP1qvGVTIBARoAvb9QYFGEKDp5v9e/?a=1988&bti=ODszNWYuMDE6&&bt=1793&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=aDlmNGg7ZTdlaGY5ZzUzZ0BpanJneW45cmVpOzMzaTczNEBjXl5hMzAvXy8xNDAuYmIyYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=db26af0a98ff05e504d7121b25048527&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oIygxEfEVAP1qvGVTIBARoAvb9QYFGEKDp5v9e/?a=1988&bti=ODszNWYuMDE6&&bt=1793&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=aDlmNGg7ZTdlaGY5ZzUzZ0BpanJneW45cmVpOzMzaTczNEBjXl5hMzAvXy8xNDAuYmIyYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=db26af0a98ff05e504d7121b25048527&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=5de0e21d367c4e2bb42b386ed1aca6fe&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjAzMmU1YzJlMTAwMDJiYjIwMmFiN2RhNTIzOWVhYWRk&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 1080
|
||||
},
|
||||
"QualityType": 2,
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 385342}, {\\\"time\\\": 2, \\\"offset\\\": 561014}, {\\\"time\\\": 3, \\\"offset\\\": 703814}, {\\\"time\\\": 4, \\\"offset\\\": 855867}, {\\\"time\\\": 5, \\\"offset\\\": 1019333}, {\\\"time\\\": 10, \\\"offset\\\": 2028412}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 96.83, \\\"v960\\\": 97.64, \\\"v864\\\": 98.256, \\\"v720\\\": 98.838}, \\\"ori\\\": {\\\"v1080\\\": 91.834, \\\"v960\\\": 93.423, \\\"v864\\\": 94.54, \\\"v720\\\": 95.872}}}\",\"ufq\":\"{\\\"enh\\\":89.317,\\\"playback\\\":{\\\"ori\\\":85.151,\\\"srv1\\\":90.147},\\\"src\\\":85.957,\\\"trans\\\":85.151,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"11b0345f3bf185ddbe7c48bd7301980f\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":96215,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwCWAACdzkgS\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwCWAACgAhyAHgWWdzkySUQs0RJJJJify4d/1+bPl/1+M4gQ5qAgICCAAAADAIAAAA8E\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 1773703,
|
||||
"BitrateFPS": 29,
|
||||
"CodecType": "h264",
|
||||
"Format": "mp4",
|
||||
"GearName": "normal_720_0",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 84.77, \"v960\": 88.554, \"v864\": 88.982, \"v720\": 92.772}, \"ori\": {\"v1080\": 84.77, \"v960\": 88.554, \"v864\": 88.982, \"v720\": 92.772}}}",
|
||||
"PlayAddr": {
|
||||
"DataSize": 13761060,
|
||||
"FileCs": "c:0-51456-f8d1",
|
||||
"FileHash": "485174c8492db027adbbf6dca7c7c201",
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_720p_1773703",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=aa5be80671c4a73d54517ee8e4a471ad&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=aa5be80671c4a73d54517ee8e4a471ad&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=9de1abcaf21d44d081117ee2ca3d4cc2&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjcwMGExZmI3NDg4YzFiZTIwZjczM2IyMmM0OTk2YTI2&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 720
|
||||
},
|
||||
"QualityType": 10,
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 400712}, {\\\"time\\\": 2, \\\"offset\\\": 659243}, {\\\"time\\\": 3, \\\"offset\\\": 886968}, {\\\"time\\\": 4, \\\"offset\\\": 1101663}, {\\\"time\\\": 5, \\\"offset\\\": 1368565}, {\\\"time\\\": 10, \\\"offset\\\": 2375704}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 84.77, \\\"v960\\\": 88.554, \\\"v864\\\": 88.982, \\\"v720\\\": 92.772}, \\\"ori\\\": {\\\"v1080\\\": 84.77, \\\"v960\\\": 88.554, \\\"v864\\\": 88.982, \\\"v720\\\": 92.772}}}\",\"ufq\":\"\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"65b88c41c0963253ad31b6f68981a242\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":64193}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 966380,
|
||||
"BitrateFPS": 29,
|
||||
"CodecType": "h265_hvc1",
|
||||
"Format": "mp4",
|
||||
"GearName": "adapt_lower_720_1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 90.05, \"v960\": 91.958, \"v864\": 93.608, \"v720\": 95.869}, \"ori\": {\"v1080\": 82.259, \"v960\": 85.339, \"v864\": 87.288, \"v720\": 90.851}}}",
|
||||
"PlayAddr": {
|
||||
"DataSize": 7497540,
|
||||
"FileCs": "c:0-52786-7f65",
|
||||
"FileHash": "1376df24c917669727b27273697fb826",
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_720p_966380",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/og99rgOGqJED8xGeBpFEAADvEPARnbTIQTfoVv/?a=1988&bti=ODszNWYuMDE6&&bt=943&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=OTNkNDk2NzlmM2VkPGg7OkBpanJneW45cmVpOzMzaTczNEBeMmBiYWBfNWIxLzEyNF81YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=e3690d67b2ecae289320f5db6c338d05&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/og99rgOGqJED8xGeBpFEAADvEPARnbTIQTfoVv/?a=1988&bti=ODszNWYuMDE6&&bt=943&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=OTNkNDk2NzlmM2VkPGg7OkBpanJneW45cmVpOzMzaTczNEBeMmBiYWBfNWIxLzEyNF81YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=e3690d67b2ecae289320f5db6c338d05&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=87b87e594a2b40ec86b4c3d6e1ca50a6&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLmRjNWNmZGVmMmFkNTIyNWQ1ZTA1NzExM2NjOTVlNzMy&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 720
|
||||
},
|
||||
"QualityType": 14,
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 284307}, {\\\"time\\\": 2, \\\"offset\\\": 357012}, {\\\"time\\\": 3, \\\"offset\\\": 438159}, {\\\"time\\\": 4, \\\"offset\\\": 532720}, {\\\"time\\\": 5, \\\"offset\\\": 636824}, {\\\"time\\\": 10, \\\"offset\\\": 1142427}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 90.05, \\\"v960\\\": 91.958, \\\"v864\\\": 93.608, \\\"v720\\\": 95.869}, \\\"ori\\\": {\\\"v1080\\\": 82.259, \\\"v960\\\": 85.339, \\\"v864\\\": 87.288, \\\"v720\\\": 90.851}}}\",\"ufq\":\"{\\\"enh\\\":89.767,\\\"playback\\\":{\\\"ori\\\":76.711,\\\"srv1\\\":84.502},\\\"src\\\":85.957,\\\"trans\\\":76.711,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"97eb57dffd9e7a0333979f6ca6c9418d\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 91.625, \\\"sr20\\\": 97.355}}\",\"audio_bit_rate\":96215,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwC6AACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwC6AACgBaIAUBZYgkSXJIkQTPCEREREREYR8xPOX+/+v82fy/+v8yh+S/3/1/mz+X/1/jOEAg5qAgICCAAAAwAIAAADAPBA\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 799620,
|
||||
"BitrateFPS": 29,
|
||||
"CodecType": "h265_hvc1",
|
||||
"Format": "mp4",
|
||||
"GearName": "adapt_540_1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 87.061, \"v960\": 89.844, \"v864\": 91.813, \"v720\": 94.499}, \"ori\": {\"v1080\": 77.717, \"v960\": 80.972, \"v864\": 83.632, \"v720\": 87.945}}}",
|
||||
"PlayAddr": {
|
||||
"DataSize": 6203756,
|
||||
"FileCs": "c:0-52786-7f6d",
|
||||
"FileHash": "41f62f740ad13045000450f28b9ff2cb",
|
||||
"Height": 1024,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_540p_799620",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/oogvkDPHIAVvTxgEpE9EMoALxQRRA1FGqeB9Gf/?a=1988&bti=ODszNWYuMDE6&&bt=780&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NThkNGRmNTo4MztpOTM3ZkBpanJneW45cmVpOzMzaTczNEAyNDMuNC0xNWExYjBiX2FgYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=05aaeb04a6cf8d012bba46bb131f00af&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/oogvkDPHIAVvTxgEpE9EMoALxQRRA1FGqeB9Gf/?a=1988&bti=ODszNWYuMDE6&&bt=780&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NThkNGRmNTo4MztpOTM3ZkBpanJneW45cmVpOzMzaTczNEAyNDMuNC0xNWExYjBiX2FgYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=05aaeb04a6cf8d012bba46bb131f00af&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=ccd4b0e63fe840d587420c7a116a7552&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLmRlMWFkM2IxY2JkODUxODVlZDJjMDFjYTJmZWE0NTEx&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 576
|
||||
},
|
||||
"QualityType": 28,
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 254747}, {\\\"time\\\": 2, \\\"offset\\\": 310409}, {\\\"time\\\": 3, \\\"offset\\\": 378126}, {\\\"time\\\": 4, \\\"offset\\\": 458158}, {\\\"time\\\": 5, \\\"offset\\\": 546764}, {\\\"time\\\": 10, \\\"offset\\\": 946117}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 87.061, \\\"v960\\\": 89.844, \\\"v864\\\": 91.813, \\\"v720\\\": 94.499}, \\\"ori\\\": {\\\"v1080\\\": 77.717, \\\"v960\\\": 80.972, \\\"v864\\\": 83.632, \\\"v720\\\": 87.945}}}\",\"ufq\":\"{\\\"enh\\\":89.767,\\\"playback\\\":{\\\"ori\\\":73.304,\\\"srv1\\\":82.648},\\\"src\\\":85.957,\\\"trans\\\":73.304,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"86fda13c754bde67159dea14b7d55d6a\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 87.963, \\\"sr20\\\": 95.722}}\",\"audio_bit_rate\":64143,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwC6AACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwC6AACgBIIAQBZYgkSXJIkQTPCEREREREYR8xPOX+/+v82fy/+v8yh+S/3/1/mz+X/1/jOEAg5qAgICCAAAAwAIAAADAPBA\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 622546,
|
||||
"BitrateFPS": 29,
|
||||
"CodecType": "h264",
|
||||
"Format": "mp4",
|
||||
"GearName": "lowest_540_0",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 72.483, \"v960\": 75.424, \"v864\": 79.843, \"v720\": 84.897}, \"ori\": {\"v1080\": 64.527, \"v960\": 67.93, \"v864\": 72.279, \"v720\": 74.495}}}",
|
||||
"PlayAddr": {
|
||||
"DataSize": 4829948,
|
||||
"FileCs": "c:0-51513-3d83",
|
||||
"FileHash": "a74fd8fabed8b36131a8e31bf78e9d1d",
|
||||
"Height": 1024,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_540p_622546",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/o8vENAqIe9xD3BG8AvVUvfEqFEARhgPpHGoTJQ/?a=1988&bti=ODszNWYuMDE6&&bt=607&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=aGhoaDQ8ZTdlNGhmM2Q8ZkBpanJneW45cmVpOzMzaTczNEA1NjRgNTJjNi8xMl42NmIuYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=fa8ddc2bcbad8ebb4394c4a8b55b28df&tk=tt_chain_token&btag=e00088000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/o8vENAqIe9xD3BG8AvVUvfEqFEARhgPpHGoTJQ/?a=1988&bti=ODszNWYuMDE6&&bt=607&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=aGhoaDQ8ZTdlNGhmM2Q8ZkBpanJneW45cmVpOzMzaTczNEA1NjRgNTJjNi8xMl42NmIuYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=fa8ddc2bcbad8ebb4394c4a8b55b28df&tk=tt_chain_token&btag=e00088000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=1ce99a90ac5e412b94fb5981ce7e9e8e&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjU0ZWZiNDU4NWVmYzFlY2IyNmEyODAyMGY0ZWJjMGZj&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"Width": 576
|
||||
},
|
||||
"QualityType": 25,
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 176720}, {\\\"time\\\": 2, \\\"offset\\\": 268125}, {\\\"time\\\": 3, \\\"offset\\\": 342814}, {\\\"time\\\": 4, \\\"offset\\\": 414132}, {\\\"time\\\": 5, \\\"offset\\\": 511717}, {\\\"time\\\": 10, \\\"offset\\\": 841405}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 72.483, \\\"v960\\\": 75.424, \\\"v864\\\": 79.843, \\\"v720\\\": 84.897}, \\\"ori\\\": {\\\"v1080\\\": 64.527, \\\"v960\\\": 67.93, \\\"v864\\\": 72.279, \\\"v720\\\": 74.495}}}\",\"ufq\":\"\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"25b3faebdda74a9e438e851988a6efcc\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":32099}"
|
||||
}
|
||||
],
|
||||
"claInfo": {
|
||||
"enableAutoCaption": true,
|
||||
"hasOriginalAudio": true,
|
||||
"noCaptionReason": 3
|
||||
},
|
||||
"codecType": "h264",
|
||||
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=ZIp3Rdhs6GNzMatSMsNWHFZX%2F7s%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"definition": "720p",
|
||||
"duration": 62,
|
||||
"dynamicCover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oYVoPG0RxQTGE89EEqvAuADpFvfGABVMxQBeza~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=JWF%2F5g2txpFXhNhJzOwleSJgKik%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"encodeUserTag": "",
|
||||
"encodedType": "normal",
|
||||
"format": "mp4",
|
||||
"height": 1280,
|
||||
"id": "7655821461772406047",
|
||||
"originCover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oYVoQGFsU9VvLxieEDDAQAPpRTqA8B5EzvEGfF~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=yHDGczHGo3joyDeerZP6Dp4S1Jc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"playAddr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12CGneI3wUcfzAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698153&l=2026070815413036F7D0D0506FB569C78C&ply_type=2&policy=2&signature=aa5be80671c4a73d54517ee8e4a471ad&tk=tt_chain_token&btag=e00088000",
|
||||
"ratio": "720p",
|
||||
"size": 13761060,
|
||||
"videoID": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"videoQuality": "normal",
|
||||
"volumeInfo": {
|
||||
"Loudness": -48.5,
|
||||
"Peak": 0.18408
|
||||
},
|
||||
"width": 720,
|
||||
"zoomCover": {
|
||||
"240": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:240:240.avif?dr=9616&x-expires=1783695600&x-signature=ZONw1V50ZmupszDX0NiC%2Bf8BimU%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"480": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:480:480.avif?dr=9616&x-expires=1783695600&x-signature=LztWYGkvUC4%2BvhtQb0TO0l1ky0s%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"720": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:720:720.avif?dr=9616&x-expires=1783695600&x-signature=X%2F3HQ%2BkiKqEAxnuk%2BdFfBfeOsko%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"960": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:960:960.avif?dr=9616&x-expires=1783695600&x-signature=aJaYG%2BDAo%2BhO7nZ8t8XP7g0DArU%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
660
surfsense_backend/tests/fixtures/tiktok/video_item_struct.json
vendored
Normal file
660
surfsense_backend/tests/fixtures/tiktok/video_item_struct.json
vendored
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
{
|
||||
"id": "7655821461772406047",
|
||||
"desc": "Pull Apart Garlic Bread Ingredients * 1 tsp yeast * 1 tbsp sugar * 270g milk (about 1 cup + 2 tbsp) * 380g flour (about 3 cups) * 1 tsp salt * 43g softened butter (about 3 tbsp) Butter Mixture * 100g butter, softened * 1 tbsp chopped cilantro * 1 garlic clove, minced * Shredded cheese Instructions 1. In a bowl, mix the sugar and yeast. Add the milk, then add the flour and mix until combined. 2. Add the salt and softened butter, then coil fold until the butter is fully incorporated. 3. Cover and let the dough rest for 2 hours or until doubled. 4. Mix the softened butter with the cilantro and minced garlic. 5. Roll the dough into a rectangle and spread the garlic butter mixture evenly over it. Sprinkle with shredded cheddar cheese. 6. Cut the dough in half, stack one half on top of the other, then cut into strips. Transfer the pieces into a loaf pan. 7. Cover and let rest for 20 minutes. 8. Bake at 370°F for 30 minutes, or until golden brown. 9. Brush with the remaining garlic butter mixture while still warm. #bread #garlicbread #breadtok #baking #homemadebread ",
|
||||
"createTime": "1782509863",
|
||||
"scheduleTime": 0,
|
||||
"video": {
|
||||
"id": "7655821461772406047",
|
||||
"height": 1280,
|
||||
"width": 720,
|
||||
"duration": 62,
|
||||
"ratio": "720p",
|
||||
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=ZIp3Rdhs6GNzMatSMsNWHFZX%2F7s%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"originCover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oYVoQGFsU9VvLxieEDDAQAPpRTqA8B5EzvEGfF~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=yHDGczHGo3joyDeerZP6Dp4S1Jc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"dynamicCover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oYVoPG0RxQTGE89EEqvAuADpFvfGABVMxQBeza~tplv-tiktokx-origin.image?dr=9636&x-expires=1783695600&x-signature=Jz%2BPtpSXdbjTV4qFbG4H7JFD0f8%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5",
|
||||
"playAddr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=1bb5fb748a97085b872e357d1cd5406e&tk=tt_chain_token&btag=e00090000",
|
||||
"downloadAddr": "",
|
||||
"shareCover": [
|
||||
""
|
||||
],
|
||||
"reflowCover": "",
|
||||
"bitrate": 1773703,
|
||||
"encodedType": "normal",
|
||||
"format": "mp4",
|
||||
"videoQuality": "normal",
|
||||
"encodeUserTag": "",
|
||||
"codecType": "h264",
|
||||
"definition": "720p",
|
||||
"subtitleInfos": [],
|
||||
"zoomCover": {
|
||||
"240": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:240:240.avif?dr=9616&x-expires=1783695600&x-signature=cBXoX16WOKJyUth9fnaohYv%2B%2BBs%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"480": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:480:480.avif?dr=9616&x-expires=1783695600&x-signature=Ff5B1aV4teSPQc6JXMoidMXqJWk%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"720": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:720:720.avif?dr=9616&x-expires=1783695600&x-signature=X%2F3HQ%2BkiKqEAxnuk%2BdFfBfeOsko%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1",
|
||||
"960": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/oAiefnCIc6NNSgtgrKLRA8JGAMoVfgJSe8eiRS~tplv-photomode-zoomcover:960:960.avif?dr=9616&x-expires=1783695600&x-signature=aJaYG%2BDAo%2BhO7nZ8t8XP7g0DArU%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast5&ftpl=1"
|
||||
},
|
||||
"volumeInfo": {
|
||||
"Loudness": -48.5,
|
||||
"Peak": 0.18408
|
||||
},
|
||||
"bitrateInfo": [
|
||||
{
|
||||
"Bitrate": 1836180,
|
||||
"QualityType": 2,
|
||||
"BitrateFPS": 29,
|
||||
"GearName": "adapt_lowest_1080_1",
|
||||
"PlayAddr": {
|
||||
"DataSize": "14245777",
|
||||
"Width": 1080,
|
||||
"Height": 1920,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oIygxEfEVAP1qvGVTIBARoAvb9QYFGEKDp5v9e/?a=1988&bti=ODszNWYuMDE6&&bt=1793&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=aDlmNGg7ZTdlaGY5ZzUzZ0BpanJneW45cmVpOzMzaTczNEBjXl5hMzAvXy8xNDAuYmIyYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=0648319cea95ee08fd7b4f93a95cb14c&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oIygxEfEVAP1qvGVTIBARoAvb9QYFGEKDp5v9e/?a=1988&bti=ODszNWYuMDE6&&bt=1793&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=aDlmNGg7ZTdlaGY5ZzUzZ0BpanJneW45cmVpOzMzaTczNEBjXl5hMzAvXy8xNDAuYmIyYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=0648319cea95ee08fd7b4f93a95cb14c&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=5de0e21d367c4e2bb42b386ed1aca6fe&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjAzMmU1YzJlMTAwMDJiYjIwMmFiN2RhNTIzOWVhYWRk&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_1080p_1836180",
|
||||
"FileHash": "38996a98cfeb2714fca1b94768090c37",
|
||||
"FileCs": "c:0-52754-ed06"
|
||||
},
|
||||
"CodecType": "h265_hvc1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 96.83, \"v960\": 97.64, \"v864\": 98.256, \"v720\": 98.838}, \"ori\": {\"v1080\": 91.834, \"v960\": 93.423, \"v864\": 94.54, \"v720\": 95.872}}}",
|
||||
"Format": "mp4",
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 385342}, {\\\"time\\\": 2, \\\"offset\\\": 561014}, {\\\"time\\\": 3, \\\"offset\\\": 703814}, {\\\"time\\\": 4, \\\"offset\\\": 855867}, {\\\"time\\\": 5, \\\"offset\\\": 1019333}, {\\\"time\\\": 10, \\\"offset\\\": 2028412}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 96.83, \\\"v960\\\": 97.64, \\\"v864\\\": 98.256, \\\"v720\\\": 98.838}, \\\"ori\\\": {\\\"v1080\\\": 91.834, \\\"v960\\\": 93.423, \\\"v864\\\": 94.54, \\\"v720\\\": 95.872}}}\",\"ufq\":\"{\\\"enh\\\":89.317,\\\"playback\\\":{\\\"ori\\\":85.151,\\\"srv1\\\":90.147},\\\"src\\\":85.957,\\\"trans\\\":85.151,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"11b0345f3bf185ddbe7c48bd7301980f\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":96215,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwCWAACdzkgS\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwCWAACgAhyAHgWWdzkySUQs0RJJJJify4d/1+bPl/1+M4gQ5qAgICCAAAADAIAAAA8E\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 1773703,
|
||||
"QualityType": 10,
|
||||
"BitrateFPS": 29,
|
||||
"GearName": "normal_720_0",
|
||||
"PlayAddr": {
|
||||
"DataSize": "13761060",
|
||||
"Width": 720,
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=1bb5fb748a97085b872e357d1cd5406e&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=1bb5fb748a97085b872e357d1cd5406e&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=9de1abcaf21d44d081117ee2ca3d4cc2&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjcwMGExZmI3NDg4YzFiZTIwZjczM2IyMmM0OTk2YTI2&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_720p_1773703",
|
||||
"FileHash": "485174c8492db027adbbf6dca7c7c201",
|
||||
"FileCs": "c:0-51456-f8d1"
|
||||
},
|
||||
"CodecType": "h264",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 84.77, \"v960\": 88.554, \"v864\": 88.982, \"v720\": 92.772}, \"ori\": {\"v1080\": 84.77, \"v960\": 88.554, \"v864\": 88.982, \"v720\": 92.772}}}",
|
||||
"Format": "mp4",
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 400712}, {\\\"time\\\": 2, \\\"offset\\\": 659243}, {\\\"time\\\": 3, \\\"offset\\\": 886968}, {\\\"time\\\": 4, \\\"offset\\\": 1101663}, {\\\"time\\\": 5, \\\"offset\\\": 1368565}, {\\\"time\\\": 10, \\\"offset\\\": 2375704}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 84.77, \\\"v960\\\": 88.554, \\\"v864\\\": 88.982, \\\"v720\\\": 92.772}, \\\"ori\\\": {\\\"v1080\\\": 84.77, \\\"v960\\\": 88.554, \\\"v864\\\": 88.982, \\\"v720\\\": 92.772}}}\",\"ufq\":\"\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"65b88c41c0963253ad31b6f68981a242\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":64193}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 966380,
|
||||
"QualityType": 14,
|
||||
"BitrateFPS": 29,
|
||||
"GearName": "adapt_lower_720_1",
|
||||
"PlayAddr": {
|
||||
"DataSize": "7497540",
|
||||
"Width": 720,
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/og99rgOGqJED8xGeBpFEAADvEPARnbTIQTfoVv/?a=1988&bti=ODszNWYuMDE6&&bt=943&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=OTNkNDk2NzlmM2VkPGg7OkBpanJneW45cmVpOzMzaTczNEBeMmBiYWBfNWIxLzEyNF81YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=3a0c7313f1a5d9908cc2f5b0dd24d048&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/og99rgOGqJED8xGeBpFEAADvEPARnbTIQTfoVv/?a=1988&bti=ODszNWYuMDE6&&bt=943&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=OTNkNDk2NzlmM2VkPGg7OkBpanJneW45cmVpOzMzaTczNEBeMmBiYWBfNWIxLzEyNF81YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=3a0c7313f1a5d9908cc2f5b0dd24d048&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=87b87e594a2b40ec86b4c3d6e1ca50a6&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLmRjNWNmZGVmMmFkNTIyNWQ1ZTA1NzExM2NjOTVlNzMy&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_720p_966380",
|
||||
"FileHash": "1376df24c917669727b27273697fb826",
|
||||
"FileCs": "c:0-52786-7f65"
|
||||
},
|
||||
"CodecType": "h265_hvc1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 90.05, \"v960\": 91.958, \"v864\": 93.608, \"v720\": 95.869}, \"ori\": {\"v1080\": 82.259, \"v960\": 85.339, \"v864\": 87.288, \"v720\": 90.851}}}",
|
||||
"Format": "mp4",
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 284307}, {\\\"time\\\": 2, \\\"offset\\\": 357012}, {\\\"time\\\": 3, \\\"offset\\\": 438159}, {\\\"time\\\": 4, \\\"offset\\\": 532720}, {\\\"time\\\": 5, \\\"offset\\\": 636824}, {\\\"time\\\": 10, \\\"offset\\\": 1142427}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 90.05, \\\"v960\\\": 91.958, \\\"v864\\\": 93.608, \\\"v720\\\": 95.869}, \\\"ori\\\": {\\\"v1080\\\": 82.259, \\\"v960\\\": 85.339, \\\"v864\\\": 87.288, \\\"v720\\\": 90.851}}}\",\"ufq\":\"{\\\"enh\\\":89.767,\\\"playback\\\":{\\\"ori\\\":76.711,\\\"srv1\\\":84.502},\\\"src\\\":85.957,\\\"trans\\\":76.711,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"97eb57dffd9e7a0333979f6ca6c9418d\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 91.625, \\\"sr20\\\": 97.355}}\",\"audio_bit_rate\":96215,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwC6AACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwC6AACgBaIAUBZYgkSXJIkQTPCEREREREYR8xPOX+/+v82fy/+v8yh+S/3/1/mz+X/1/jOEAg5qAgICCAAAAwAIAAADAPBA\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 799620,
|
||||
"QualityType": 28,
|
||||
"BitrateFPS": 29,
|
||||
"GearName": "adapt_540_1",
|
||||
"PlayAddr": {
|
||||
"DataSize": "6203756",
|
||||
"Width": 576,
|
||||
"Height": 1024,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/oogvkDPHIAVvTxgEpE9EMoALxQRRA1FGqeB9Gf/?a=1988&bti=ODszNWYuMDE6&&bt=780&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NThkNGRmNTo4MztpOTM3ZkBpanJneW45cmVpOzMzaTczNEAyNDMuNC0xNWExYjBiX2FgYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=a8516d4d8e79814753dc2cd0fde9f7bd&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/oogvkDPHIAVvTxgEpE9EMoALxQRRA1FGqeB9Gf/?a=1988&bti=ODszNWYuMDE6&&bt=780&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NThkNGRmNTo4MztpOTM3ZkBpanJneW45cmVpOzMzaTczNEAyNDMuNC0xNWExYjBiX2FgYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=a8516d4d8e79814753dc2cd0fde9f7bd&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=ccd4b0e63fe840d587420c7a116a7552&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLmRlMWFkM2IxY2JkODUxODVlZDJjMDFjYTJmZWE0NTEx&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_bytevc1_540p_799620",
|
||||
"FileHash": "41f62f740ad13045000450f28b9ff2cb",
|
||||
"FileCs": "c:0-52786-7f6d"
|
||||
},
|
||||
"CodecType": "h265_hvc1",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 87.061, \"v960\": 89.844, \"v864\": 91.813, \"v720\": 94.499}, \"ori\": {\"v1080\": 77.717, \"v960\": 80.972, \"v864\": 83.632, \"v720\": 87.945}}}",
|
||||
"Format": "mp4",
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 254747}, {\\\"time\\\": 2, \\\"offset\\\": 310409}, {\\\"time\\\": 3, \\\"offset\\\": 378126}, {\\\"time\\\": 4, \\\"offset\\\": 458158}, {\\\"time\\\": 5, \\\"offset\\\": 546764}, {\\\"time\\\": 10, \\\"offset\\\": 946117}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 87.061, \\\"v960\\\": 89.844, \\\"v864\\\": 91.813, \\\"v720\\\": 94.499}, \\\"ori\\\": {\\\"v1080\\\": 77.717, \\\"v960\\\": 80.972, \\\"v864\\\": 83.632, \\\"v720\\\": 87.945}}}\",\"ufq\":\"{\\\"enh\\\":89.767,\\\"playback\\\":{\\\"ori\\\":73.304,\\\"srv1\\\":82.648},\\\"src\\\":85.957,\\\"trans\\\":73.304,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"86fda13c754bde67159dea14b7d55d6a\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 87.963, \\\"sr20\\\": 95.722}}\",\"audio_bit_rate\":64143,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwC6AACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwC6AACgBIIAQBZYgkSXJIkQTPCEREREREYR8xPOX+/+v82fy/+v8yh+S/3/1/mz+X/1/jOEAg5qAgICCAAAAwAIAAADAPBA\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"Bitrate": 622546,
|
||||
"QualityType": 25,
|
||||
"BitrateFPS": 29,
|
||||
"GearName": "lowest_540_0",
|
||||
"PlayAddr": {
|
||||
"DataSize": "4829948",
|
||||
"Width": 576,
|
||||
"Height": 1024,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/o8vENAqIe9xD3BG8AvVUvfEqFEARhgPpHGoTJQ/?a=1988&bti=ODszNWYuMDE6&&bt=607&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=aGhoaDQ8ZTdlNGhmM2Q8ZkBpanJneW45cmVpOzMzaTczNEA1NjRgNTJjNi8xMl42NmIuYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=f44ac110e4573a7bc11c73155b9141d0&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c001-tx2/o8vENAqIe9xD3BG8AvVUvfEqFEARhgPpHGoTJQ/?a=1988&bti=ODszNWYuMDE6&&bt=607&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=aGhoaDQ8ZTdlNGhmM2Q8ZkBpanJneW45cmVpOzMzaTczNEA1NjRgNTJjNi8xMl42NmIuYSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=f44ac110e4573a7bc11c73155b9141d0&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=1ce99a90ac5e412b94fb5981ce7e9e8e&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjU0ZWZiNDU4NWVmYzFlY2IyNmEyODAyMGY0ZWJjMGZj&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_540p_622546",
|
||||
"FileHash": "a74fd8fabed8b36131a8e31bf78e9d1d",
|
||||
"FileCs": "c:0-51513-3d83"
|
||||
},
|
||||
"CodecType": "h264",
|
||||
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 72.483, \"v960\": 75.424, \"v864\": 79.843, \"v720\": 84.897}, \"ori\": {\"v1080\": 64.527, \"v960\": 67.93, \"v864\": 72.279, \"v720\": 74.495}}}",
|
||||
"Format": "mp4",
|
||||
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 176720}, {\\\"time\\\": 2, \\\"offset\\\": 268125}, {\\\"time\\\": 3, \\\"offset\\\": 342814}, {\\\"time\\\": 4, \\\"offset\\\": 414132}, {\\\"time\\\": 5, \\\"offset\\\": 511717}, {\\\"time\\\": 10, \\\"offset\\\": 841405}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 72.483, \\\"v960\\\": 75.424, \\\"v864\\\": 79.843, \\\"v720\\\": 84.897}, \\\"ori\\\": {\\\"v1080\\\": 64.527, \\\"v960\\\": 67.93, \\\"v864\\\": 72.279, \\\"v720\\\": 74.495}}}\",\"ufq\":\"\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"25b3faebdda74a9e438e851988a6efcc\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":32099}"
|
||||
}
|
||||
],
|
||||
"size": "13761060",
|
||||
"VQScore": "69.69",
|
||||
"claInfo": {
|
||||
"hasOriginalAudio": true,
|
||||
"enableAutoCaption": true,
|
||||
"captionInfos": [],
|
||||
"noCaptionReason": 3
|
||||
},
|
||||
"videoID": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"PlayAddrStruct": {
|
||||
"DataSize": "13761060",
|
||||
"Width": 720,
|
||||
"Height": 1280,
|
||||
"Uri": "v15044gf0000d8vf0bvog65kkvkd8ohg",
|
||||
"UrlList": [
|
||||
"https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=1bb5fb748a97085b872e357d1cd5406e&tk=tt_chain_token&btag=e00090000",
|
||||
"https://v19-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-pve-0068-tx2/oYDTerfAgGxvqFaEctpBAvVGEPIE8Q9ARoxPNB/?a=1988&bti=ODszNWYuMDE6&&bt=1732&ft=aEeq8qT0mIoPD12qGneI3wU6LRAbMeF~O5&mime_type=video_mp4&rc=NWZnZDVoNDQzN2c1ZGU0Z0BpanJneW45cmVpOzMzaTczNEBgMTBgYjQuNWExLmNgXmI2YSNlNWhoMmRzLXNhLS1kMTJzcw%3D%3D&expire=1783698158&l=20260708154135271AF032BAC01F42EF1E&ply_type=2&policy=2&signature=1bb5fb748a97085b872e357d1cd5406e&tk=tt_chain_token&btag=e00090000",
|
||||
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=9de1abcaf21d44d081117ee2ca3d4cc2&is_play_url=1&item_id=7655821461772406047&line=0&ply_type=2&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjcwMGExZmI3NDg4YzFiZTIwZjczM2IyMmM0OTk2YTI2&tk=tt_chain_token&video_id=v15044gf0000d8vf0bvog65kkvkd8ohg"
|
||||
],
|
||||
"UrlKey": "v15044gf0000d8vf0bvog65kkvkd8ohg_h264_720p_1773703",
|
||||
"FileHash": "485174c8492db027adbbf6dca7c7c201",
|
||||
"FileCs": "c:0-51456-f8d1"
|
||||
}
|
||||
},
|
||||
"author": {
|
||||
"id": "7501138183701103662",
|
||||
"shortId": "",
|
||||
"uniqueId": "cookwithhali",
|
||||
"nickname": "Cookwithhali",
|
||||
"avatarLarger": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=9640&refresh_token=c6d77c8a&x-expires=1783695600&x-signature=mUTuvDWLEGrAxXtWZlBE7gUxCl8%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"avatarMedium": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:720:720.jpeg?dr=9640&refresh_token=dfe18cb8&x-expires=1783695600&x-signature=cP14vkr0ndxwhW%2B1pWfefXfTsls%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"avatarThumb": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:100:100.jpeg?dr=9640&refresh_token=84bba9f5&x-expires=1783695600&x-signature=Et06On6iQeXFbtycXoVodzl0fdM%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"signature": "From my kitchen to your screen! 🍞\nFollow for more ✨\nIG: halikit25\n\n📩 Halikitchen25@gmail.com",
|
||||
"createTime": 1746494976,
|
||||
"verified": false,
|
||||
"secUid": "MS4wLjABAAAAywZ6lCXK3u4zKz8eQxxcjVblLiugz6zMysU98G7dyKVxu6IvMOJ97mpIfpDxUL4q",
|
||||
"ftc": false,
|
||||
"relation": 0,
|
||||
"openFavorite": false,
|
||||
"commentSetting": 0,
|
||||
"duetSetting": 0,
|
||||
"stitchSetting": 0,
|
||||
"privateAccount": false,
|
||||
"secret": false,
|
||||
"isADVirtual": false,
|
||||
"roomId": "",
|
||||
"uniqueIdModifyTime": 0,
|
||||
"ttSeller": false,
|
||||
"downloadSetting": 3,
|
||||
"recommendReason": "",
|
||||
"nowInvitationCardUrl": "",
|
||||
"nickNameModifyTime": 0,
|
||||
"isEmbedBanned": false,
|
||||
"canExpPlaylist": false,
|
||||
"suggestAccountBind": false,
|
||||
"UserStoryStatus": 0,
|
||||
"shortDramaCreator": {}
|
||||
},
|
||||
"music": {
|
||||
"id": "7655821492566919966",
|
||||
"title": "original sound",
|
||||
"playUrl": "https://v16m.tiktokcdn-us.com/84ccc1629d7199ad68f976d419148401/6a4ec44e/video/tos/useast8/tos-useast8-v-27dcd7-tx2/ocBIhANWILSemeAAINEGoTTPe58AJaRegg6eKS/?a=1233&bti=ODszNWYuMDE6&&bt=125&ft=GSDrKInz7ThasbSGXq8Zmo&mime_type=audio_mpeg&rc=anY5cXA5cmRpOzMzaTU8NEBpanY5cXA5cmRpOzMzaTU8NEBpYzZrMmRjL3NhLS1kMTJzYSNpYzZrMmRjL3NhLS1kMTJzcw%3D%3D&vvpl=1&l=20260708154135271AF032BAC01F42EF1E&btag=e00050000",
|
||||
"coverLarge": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=9640&refresh_token=c6d77c8a&x-expires=1783695600&x-signature=mUTuvDWLEGrAxXtWZlBE7gUxCl8%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"coverMedium": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:720:720.jpeg?dr=9640&refresh_token=dfe18cb8&x-expires=1783695600&x-signature=cP14vkr0ndxwhW%2B1pWfefXfTsls%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"coverThumb": "https://p16-common-sign.tiktokcdn-us.com/tos-useast8-avt-0068-tx2/b29bd2d826cffba1af22f99793a7d5dc~tplv-tiktokx-cropcenter:100:100.jpeg?dr=9640&refresh_token=84bba9f5&x-expires=1783695600&x-signature=Et06On6iQeXFbtycXoVodzl0fdM%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast5",
|
||||
"authorName": "Cookwithhali",
|
||||
"original": true,
|
||||
"private": false,
|
||||
"duration": 62,
|
||||
"scheduleSearchTime": 0,
|
||||
"collected": false,
|
||||
"preciseDuration": {
|
||||
"preciseDuration": 62.093063,
|
||||
"preciseShootDuration": 62.093063,
|
||||
"preciseAuditionDuration": 62.093063,
|
||||
"preciseVideoDuration": 62.093063
|
||||
},
|
||||
"isCopyrighted": false,
|
||||
"tt2dsp": {
|
||||
"tt_to_dsp_song_infos": []
|
||||
},
|
||||
"is_unlimited_music": false,
|
||||
"is_commerce_music": true,
|
||||
"shoot_duration": 62
|
||||
},
|
||||
"challenges": [
|
||||
{
|
||||
"id": "6983",
|
||||
"title": "bread",
|
||||
"desc": "",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": ""
|
||||
},
|
||||
{
|
||||
"id": "285169",
|
||||
"title": "garlicbread",
|
||||
"desc": "",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": ""
|
||||
},
|
||||
{
|
||||
"id": "1643317566954502",
|
||||
"title": "breadtok",
|
||||
"desc": "",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": ""
|
||||
},
|
||||
{
|
||||
"id": "7250",
|
||||
"title": "baking",
|
||||
"desc": "Whether you're baking the perfect pie or just searching for a recipe, you've come to the right place for all things #Baking.",
|
||||
"profileLarger": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"profileMedium": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"profileThumb": "https://p19-common-sign.tiktokcdn-us.com/musically-maliva-obj/1aa8a54136dc06390b83508fdd683160~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=nigLPQXvvDM%2BxFQezn3ELBH%2BLwQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": ""
|
||||
},
|
||||
{
|
||||
"id": "10488587",
|
||||
"title": "homemadebread",
|
||||
"desc": "",
|
||||
"profileLarger": "",
|
||||
"profileMedium": "",
|
||||
"profileThumb": "",
|
||||
"coverLarger": "",
|
||||
"coverMedium": "",
|
||||
"coverThumb": ""
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"diggCount": 754100,
|
||||
"shareCount": 144500,
|
||||
"commentCount": 3720,
|
||||
"playCount": 8300000,
|
||||
"collectCount": "396399"
|
||||
},
|
||||
"statsV2": {
|
||||
"diggCount": "754100",
|
||||
"shareCount": "144500",
|
||||
"commentCount": "3720",
|
||||
"playCount": "8300000",
|
||||
"collectCount": "396399",
|
||||
"repostCount": "0"
|
||||
},
|
||||
"warnInfo": [],
|
||||
"originalItem": false,
|
||||
"officalItem": false,
|
||||
"textExtra": [
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 1024,
|
||||
"end": 1030,
|
||||
"hashtagId": "6983",
|
||||
"hashtagName": "bread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 1031,
|
||||
"end": 1043,
|
||||
"hashtagId": "285169",
|
||||
"hashtagName": "garlicbread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 1044,
|
||||
"end": 1053,
|
||||
"hashtagId": "1643317566954502",
|
||||
"hashtagName": "breadtok",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 1054,
|
||||
"end": 1061,
|
||||
"hashtagId": "7250",
|
||||
"hashtagName": "baking",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 1062,
|
||||
"end": 1076,
|
||||
"hashtagId": "10488587",
|
||||
"hashtagName": "homemadebread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
}
|
||||
],
|
||||
"secret": false,
|
||||
"forFriend": false,
|
||||
"digged": false,
|
||||
"itemCommentStatus": 0,
|
||||
"takeDown": 0,
|
||||
"effectStickers": [],
|
||||
"authorStats": {
|
||||
"followerCount": 372600,
|
||||
"followingCount": 0,
|
||||
"heart": 8000000,
|
||||
"heartCount": 8000000,
|
||||
"videoCount": 279,
|
||||
"diggCount": 223,
|
||||
"friendCount": 0
|
||||
},
|
||||
"privateItem": false,
|
||||
"duetEnabled": true,
|
||||
"stitchEnabled": true,
|
||||
"stickersOnItem": [
|
||||
{
|
||||
"stickerText": [
|
||||
"Cheesy Pull Apart Garlic Bread! 🧄🧀\n",
|
||||
"(Bakery-Style & No-Knead)"
|
||||
],
|
||||
"stickerType": 4
|
||||
}
|
||||
],
|
||||
"isAd": false,
|
||||
"shareEnabled": true,
|
||||
"comments": [],
|
||||
"duetDisplay": 0,
|
||||
"stitchDisplay": 0,
|
||||
"indexEnabled": true,
|
||||
"diversificationLabels": [
|
||||
"Cooking",
|
||||
"Food & Drink",
|
||||
"Lifestyle"
|
||||
],
|
||||
"locationCreated": "US",
|
||||
"suggestedWords": [
|
||||
"Garlic Bread",
|
||||
"garlic bread recipe",
|
||||
"pull apart bread",
|
||||
"Sourdough Bread",
|
||||
"How To Make Garlic Bread",
|
||||
"Baking",
|
||||
"food",
|
||||
"bread",
|
||||
"Baking Recipes",
|
||||
"Garlic Pull Apart Bread Recipe"
|
||||
],
|
||||
"contents": [
|
||||
{
|
||||
"desc": "Pull Apart Garlic Bread ",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "Ingredients",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tsp yeast",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tbsp sugar",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 270g milk (about 1 cup + 2 tbsp)",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 380g flour (about 3 cups)",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tsp salt",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 43g softened butter (about 3 tbsp)",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "Butter Mixture",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 100g butter, softened",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 1 tbsp chopped cilantro",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* 1 garlic clove, minced",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "* Shredded cheese",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "Instructions",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "1. In a bowl, mix the sugar and yeast. Add the milk, then add the flour and mix until combined.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "2. Add the salt and softened butter, then coil fold until the butter is fully incorporated.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "3. Cover and let the dough rest for 2 hours or until doubled.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "4. Mix the softened butter with the cilantro and minced garlic.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "5. Roll the dough into a rectangle and spread the garlic butter mixture evenly over it. Sprinkle with shredded cheddar cheese.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "6. Cut the dough in half, stack one half on top of the other, then cut into strips. Transfer the pieces into a loaf pan.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "7. Cover and let rest for 20 minutes.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "8. Bake at 370°F for 30 minutes, or until golden brown.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "9. Brush with the remaining garlic butter mixture while still warm.",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "",
|
||||
"textExtra": []
|
||||
},
|
||||
{
|
||||
"desc": "#bread #garlicbread #breadtok #baking #homemadebread ",
|
||||
"textExtra": [
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 0,
|
||||
"end": 6,
|
||||
"hashtagId": "6983",
|
||||
"hashtagName": "bread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 7,
|
||||
"end": 19,
|
||||
"hashtagId": "285169",
|
||||
"hashtagName": "garlicbread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 20,
|
||||
"end": 29,
|
||||
"hashtagId": "1643317566954502",
|
||||
"hashtagName": "breadtok",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 30,
|
||||
"end": 37,
|
||||
"hashtagId": "7250",
|
||||
"hashtagName": "baking",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
},
|
||||
{
|
||||
"awemeId": "",
|
||||
"start": 38,
|
||||
"end": 52,
|
||||
"hashtagId": "10488587",
|
||||
"hashtagName": "homemadebread",
|
||||
"type": 1,
|
||||
"subType": 0,
|
||||
"isCommerce": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"diversificationId": 10040,
|
||||
"anchors": [
|
||||
{
|
||||
"id": "0",
|
||||
"type": 54,
|
||||
"keyword": "CapCut · Editing made easy",
|
||||
"icon": {
|
||||
"uri": "tiktok-obj/capcut_logo_64px_bk.png",
|
||||
"urlList": [
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=lMwgk8e0wLaM3WkpnsMqYcxZN6w%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=44xwWHkyYJdE%2FIwqS3zAAjU4XQ4%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/capcut_logo_64px_bk.png~tplv-tiktokx-origin.jpeg?dr=9627&x-expires=1783544400&x-signature=lzVxquDTAbut%2BvshGEhsFsYQc2s%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5"
|
||||
]
|
||||
},
|
||||
"schema": "https://www.capcut.com/tools/desktop-video-editor?channel=capcutpc_ttweb_anchor_edit1&download_channel=capcutpc_ttweb_anchor_edit1&enter_from=capcutpc_ttweb_anchor_edit1&force_dp=1&from_page=capcutpc_ttweb_anchor_edit1&type=tools",
|
||||
"logExtra": "{\"anchor_id\":0,\"anchor_name\":\"CapCut · Editing made easy\",\"anchor_title_detail\":\"None\",\"anchor_title_id\":21502485763,\"anchor_type\":\"TT_CAPCUT\",\"capability_key\":\"default\",\"ccep_vip\":0,\"if_race_trigger\":0,\"is_two_line\":0,\"landing_page_style_id\":21502486275,\"maker_source\":\"\",\"publish_key\":\"default\",\"template_id\":\"none\",\"tutorial_id\":\"none\",\"viamaker_anchor_capability_key_weight\":1,\"viamaker_anchor_style_idx\":-1,\"viamaker_anchor_style_source\":3,\"video_source\":0,\"video_type_id\":1}",
|
||||
"description": "CapCut · Video Editor",
|
||||
"thumbnail": {
|
||||
"uri": "tiktok-obj/64x_Capcut3x.png",
|
||||
"urlList": [
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=SJ6q86bjtCoRArlVNS1DjDDFWoc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p19-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.image?dr=9627&x-expires=1783544400&x-signature=GOZUldk%2BrxINygKT0jwxeJz9VLQ%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5",
|
||||
"https://p16-common-sign.tiktokcdn-us.com/tiktok-obj/64x_Capcut3x.png~tplv-tiktokx-origin.jpeg?dr=9627&x-expires=1783544400&x-signature=NBemEand1jjAoV81cQ1ANQzVqqc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=9b759fb9&idc=useast5"
|
||||
],
|
||||
"width": 64,
|
||||
"height": 64
|
||||
},
|
||||
"extraInfo": {
|
||||
"subtype": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"collected": false,
|
||||
"channelTags": [],
|
||||
"item_control": {
|
||||
"can_repost": true
|
||||
},
|
||||
"IsAigc": false,
|
||||
"AIGCDescription": "",
|
||||
"backendSourceEventTracking": "",
|
||||
"CategoryType": 111,
|
||||
"textLanguage": "en",
|
||||
"textTranslatable": true,
|
||||
"authorStatsV2": {
|
||||
"followerCount": "372600",
|
||||
"followingCount": "0",
|
||||
"heart": "8000000",
|
||||
"heartCount": "8000000",
|
||||
"videoCount": "279",
|
||||
"diggCount": "223",
|
||||
"friendCount": "0"
|
||||
},
|
||||
"isReviewing": false,
|
||||
"creatorAIComment": {
|
||||
"hasAITopic": false,
|
||||
"categoryList": [],
|
||||
"eligibleVideo": false,
|
||||
"notEligibleReason": 101
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ _EXPECTED_SUBAGENTS = frozenset(
|
|||
"memory",
|
||||
"onedrive",
|
||||
"reddit",
|
||||
"tiktok",
|
||||
"web_crawler",
|
||||
"youtube",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,14 @@ async def test_registry_becomes_one_tool_per_verb_plus_readers(isolate):
|
|||
tools = isolate.module.build_capability_tools(workspace_id=7, capabilities=caps)
|
||||
|
||||
by_name = {t.name: t for t in tools}
|
||||
# One tool per verb, plus the two shared run-reader tools.
|
||||
assert set(by_name) == {"web_scrape", "web_discover", "read_run", "search_run"}
|
||||
# One tool per verb, plus the shared run-reader tools.
|
||||
assert set(by_name) == {
|
||||
"web_scrape",
|
||||
"web_discover",
|
||||
"read_run",
|
||||
"search_run",
|
||||
"export_run",
|
||||
}
|
||||
assert by_name["web_scrape"].description == "web.scrape does a thing."
|
||||
assert by_name["web_scrape"].args_schema is _EchoInput
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
"""``tiktok.comments`` executor: verb input → scraper args → typed comment items.
|
||||
|
||||
Boundary mocked: the proprietary comments actor (injected fake). NOT mocked: the
|
||||
verb's own payload→args forwarding and the dict→CommentItem wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities.tiktok.comments.executor import build_comments_executor
|
||||
from app.capabilities.tiktok.comments.schemas import CommentsInput, CommentsOutput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_VIDEO = "https://www.tiktok.com/@bob/video/123"
|
||||
|
||||
|
||||
class _FakeComments:
|
||||
"""Records the urls + kwargs it was called with; returns canned items."""
|
||||
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
self.calls: list[tuple[list[str], int, int | None]] = []
|
||||
|
||||
async def __call__(
|
||||
self, video_urls: list[str], *, per_video: int, limit: int | None = None
|
||||
) -> list[dict]:
|
||||
self.calls.append((video_urls, per_video, limit))
|
||||
return self._items
|
||||
|
||||
|
||||
async def test_forwards_urls_and_limits_and_wraps_items():
|
||||
comments = _FakeComments([{"id": "1", "text": "hi"}])
|
||||
execute = build_comments_executor(comments_fn=comments)
|
||||
|
||||
out = await execute(
|
||||
CommentsInput(video_urls=[_VIDEO], comments_per_video=15, max_items=40)
|
||||
)
|
||||
|
||||
assert isinstance(out, CommentsOutput)
|
||||
assert len(out.items) == 1
|
||||
assert out.items[0].text == "hi"
|
||||
|
||||
(urls, per_video, limit) = comments.calls[0]
|
||||
assert urls == [_VIDEO]
|
||||
assert per_video == 15
|
||||
assert limit == 40
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"""``tiktok.comments`` input guards and billing: a video URL is required, bounded,
|
||||
and ErrorItems are surfaced free."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.capabilities.tiktok.comments.schemas import CommentsInput, CommentsOutput
|
||||
from app.capabilities.tiktok.scrape.schemas import MAX_TIKTOK_ITEMS, MAX_TIKTOK_SOURCES
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
_VIDEO = "https://www.tiktok.com/@bob/video/123"
|
||||
|
||||
|
||||
def test_rejects_input_with_no_url():
|
||||
with pytest.raises(ValidationError):
|
||||
CommentsInput(video_urls=[])
|
||||
|
||||
|
||||
def test_defaults_and_bounds():
|
||||
payload = CommentsInput(video_urls=[_VIDEO])
|
||||
assert payload.max_items == 20
|
||||
assert payload.comments_per_video == 20
|
||||
assert payload.estimated_units == 20
|
||||
with pytest.raises(ValidationError):
|
||||
CommentsInput(video_urls=[_VIDEO], max_items=0)
|
||||
with pytest.raises(ValidationError):
|
||||
CommentsInput(video_urls=[_VIDEO], max_items=MAX_TIKTOK_ITEMS + 1)
|
||||
|
||||
|
||||
def test_rejects_more_urls_than_the_cap():
|
||||
too_many = [f"{_VIDEO}{i}" for i in range(MAX_TIKTOK_SOURCES + 1)]
|
||||
with pytest.raises(ValidationError):
|
||||
CommentsInput(video_urls=too_many)
|
||||
|
||||
|
||||
def test_error_items_are_not_billed():
|
||||
# Real comments count; ErrorItems (bad URL / empty video) are surfaced free.
|
||||
out = CommentsOutput(
|
||||
items=[
|
||||
{"id": "1", "text": "hi"},
|
||||
{"errorCode": "no_comments", "input": "123", "error": "empty"},
|
||||
]
|
||||
)
|
||||
assert len(out.items) == 2
|
||||
assert out.billable_units == 1
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""``tiktok.scrape`` executor: verb input → actor input mapping → typed items.
|
||||
|
||||
Boundary mocked: the proprietary scraper (injected fake). NOT mocked: the verb's
|
||||
own payload→TikTokScrapeInput mapping and the dict→TikTokVideoItem wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities.tiktok.scrape.executor import build_scrape_executor
|
||||
from app.capabilities.tiktok.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||
from app.exceptions import ForbiddenError
|
||||
from app.proprietary.platforms.tiktok import TikTokAccessBlockedError, TikTokScrapeInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeScraper:
|
||||
"""Records the actor input + limit it was called with; returns canned items."""
|
||||
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
self.calls: list[tuple[TikTokScrapeInput, int | None]] = []
|
||||
|
||||
async def __call__(
|
||||
self, actor_input: TikTokScrapeInput, *, limit: int | None = None
|
||||
) -> list[dict]:
|
||||
self.calls.append((actor_input, limit))
|
||||
return self._items
|
||||
|
||||
|
||||
async def test_maps_urls_to_start_urls_and_wraps_items():
|
||||
scraper = _FakeScraper([{"id": "123", "text": "hello"}])
|
||||
execute = build_scrape_executor(scrape_fn=scraper)
|
||||
|
||||
out = await execute(ScrapeInput(urls=["https://www.tiktok.com/@nasa/video/123"]))
|
||||
|
||||
assert isinstance(out, ScrapeOutput)
|
||||
assert len(out.items) == 1
|
||||
assert out.items[0].id == "123"
|
||||
|
||||
(actor_input, _limit) = scraper.calls[0]
|
||||
assert [u.url for u in actor_input.startUrls] == [
|
||||
"https://www.tiktok.com/@nasa/video/123"
|
||||
]
|
||||
|
||||
|
||||
async def test_forwards_typed_sources_and_limit():
|
||||
scraper = _FakeScraper([])
|
||||
execute = build_scrape_executor(scrape_fn=scraper)
|
||||
|
||||
await execute(
|
||||
ScrapeInput(
|
||||
profiles=["nasa"],
|
||||
hashtags=["food"],
|
||||
search_queries=["cats"],
|
||||
results_per_page=7,
|
||||
max_items=25,
|
||||
)
|
||||
)
|
||||
|
||||
(actor_input, limit) = scraper.calls[0]
|
||||
assert actor_input.profiles == ["nasa"]
|
||||
assert actor_input.hashtags == ["food"]
|
||||
assert actor_input.searchQueries == ["cats"]
|
||||
assert actor_input.resultsPerPage == 7
|
||||
# The outer collection limit is the caller's total-item cap.
|
||||
assert limit == 25
|
||||
|
||||
|
||||
async def test_access_blocked_maps_to_forbidden():
|
||||
async def _blocked(actor_input: TikTokScrapeInput, *, limit: int | None = None):
|
||||
raise TikTokAccessBlockedError("all IPs refused")
|
||||
|
||||
execute = build_scrape_executor(scrape_fn=_blocked)
|
||||
|
||||
with pytest.raises(ForbiddenError):
|
||||
await execute(ScrapeInput(hashtags=["food"]))
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"""``tiktok.scrape`` input guards: a source is required and the batch is bounded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.capabilities.tiktok.scrape.schemas import (
|
||||
MAX_TIKTOK_ITEMS,
|
||||
MAX_TIKTOK_SOURCES,
|
||||
ScrapeInput,
|
||||
ScrapeOutput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_rejects_input_with_no_source():
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput()
|
||||
|
||||
|
||||
def test_accepts_urls_only():
|
||||
payload = ScrapeInput(urls=["https://www.tiktok.com/@nasa"])
|
||||
assert payload.profiles == []
|
||||
|
||||
|
||||
def test_accepts_hashtags_only():
|
||||
payload = ScrapeInput(hashtags=["food"])
|
||||
assert payload.hashtags == ["food"]
|
||||
|
||||
|
||||
def test_defaults_and_bounds():
|
||||
payload = ScrapeInput(hashtags=["food"])
|
||||
assert payload.max_items == 10
|
||||
assert payload.results_per_page == 10
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(hashtags=["food"], max_items=0)
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(hashtags=["food"], max_items=MAX_TIKTOK_ITEMS + 1)
|
||||
|
||||
|
||||
def test_rejects_more_sources_than_the_cap():
|
||||
too_many = [f"tag{i}" for i in range(MAX_TIKTOK_SOURCES + 1)]
|
||||
with pytest.raises(ValidationError):
|
||||
ScrapeInput(hashtags=too_many)
|
||||
|
||||
|
||||
def test_error_items_are_not_billed():
|
||||
# Real videos count; ErrorItems (blocked/empty targets) are surfaced free.
|
||||
out = ScrapeOutput(
|
||||
items=[
|
||||
{"id": "1", "webVideoUrl": "https://tiktok.com/@a/video/1"},
|
||||
{"errorCode": "no_items", "input": "nasa", "error": "blocked"},
|
||||
]
|
||||
)
|
||||
assert len(out.items) == 2
|
||||
assert out.billable_units == 1
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""The tiktok namespace registers its verb as one Capability the doors/agent read."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities import (
|
||||
tiktok, # noqa: F401 — importing the namespace registers its verbs
|
||||
)
|
||||
from app.capabilities.core import BillingUnit
|
||||
from app.capabilities.core.store import get_capability
|
||||
from app.capabilities.tiktok.comments.schemas import CommentsInput, CommentsOutput
|
||||
from app.capabilities.tiktok.scrape.schemas import ScrapeInput, ScrapeOutput
|
||||
from app.capabilities.tiktok.trending.schemas import TrendingInput, TrendingOutput
|
||||
from app.capabilities.tiktok.user_search.schemas import (
|
||||
UserSearchInput,
|
||||
UserSearchOutput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_tiktok_scrape_is_registered_and_billed_per_video():
|
||||
cap = get_capability("tiktok.scrape")
|
||||
|
||||
assert cap.name == "tiktok.scrape"
|
||||
assert cap.input_schema is ScrapeInput
|
||||
assert cap.output_schema is ScrapeOutput
|
||||
assert cap.billing_unit is BillingUnit.TIKTOK_VIDEO
|
||||
|
||||
|
||||
def test_tiktok_user_search_is_registered_and_billed_per_profile():
|
||||
cap = get_capability("tiktok.user_search")
|
||||
|
||||
assert cap.name == "tiktok.user_search"
|
||||
assert cap.input_schema is UserSearchInput
|
||||
assert cap.output_schema is UserSearchOutput
|
||||
assert cap.billing_unit is BillingUnit.TIKTOK_USER
|
||||
|
||||
|
||||
def test_tiktok_comments_is_registered_and_billed_per_comment():
|
||||
cap = get_capability("tiktok.comments")
|
||||
|
||||
assert cap.name == "tiktok.comments"
|
||||
assert cap.input_schema is CommentsInput
|
||||
assert cap.output_schema is CommentsOutput
|
||||
assert cap.billing_unit is BillingUnit.TIKTOK_COMMENT
|
||||
|
||||
|
||||
def test_tiktok_trending_is_registered_and_billed_per_video():
|
||||
cap = get_capability("tiktok.trending")
|
||||
|
||||
assert cap.name == "tiktok.trending"
|
||||
assert cap.input_schema is TrendingInput
|
||||
assert cap.output_schema is TrendingOutput
|
||||
# Trending returns videos, so it shares the per-video meter.
|
||||
assert cap.billing_unit is BillingUnit.TIKTOK_VIDEO
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""``tiktok.trending`` executor: verb input → scraper count → typed video items.
|
||||
|
||||
Boundary mocked: the proprietary trending actor (injected fake). NOT mocked: the
|
||||
verb's own count forwarding and the dict→TikTokVideoItem wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities.tiktok.trending.executor import build_trending_executor
|
||||
from app.capabilities.tiktok.trending.schemas import TrendingInput, TrendingOutput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeTrending:
|
||||
"""Records the count it was called with; returns canned items."""
|
||||
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
self.calls: list[int] = []
|
||||
|
||||
async def __call__(self, *, count: int) -> list[dict]:
|
||||
self.calls.append(count)
|
||||
return self._items
|
||||
|
||||
|
||||
async def test_forwards_count_and_wraps_items():
|
||||
trending = _FakeTrending([{"id": "1", "text": "viral"}])
|
||||
execute = build_trending_executor(trending_fn=trending)
|
||||
|
||||
out = await execute(TrendingInput(max_items=30))
|
||||
|
||||
assert isinstance(out, TrendingOutput)
|
||||
assert len(out.items) == 1
|
||||
assert out.items[0].id == "1"
|
||||
assert trending.calls == [30]
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"""``tiktok.trending`` input guards and billing: bounded count, ErrorItems free."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.capabilities.tiktok.scrape.schemas import MAX_TIKTOK_ITEMS
|
||||
from app.capabilities.tiktok.trending.schemas import TrendingInput, TrendingOutput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_defaults_and_bounds():
|
||||
payload = TrendingInput()
|
||||
assert payload.max_items == 20
|
||||
assert payload.estimated_units == 20
|
||||
with pytest.raises(ValidationError):
|
||||
TrendingInput(max_items=0)
|
||||
with pytest.raises(ValidationError):
|
||||
TrendingInput(max_items=MAX_TIKTOK_ITEMS + 1)
|
||||
|
||||
|
||||
def test_error_items_are_not_billed():
|
||||
# Real videos count; an ErrorItem (empty/withheld feed) is surfaced free.
|
||||
out = TrendingOutput(
|
||||
items=[
|
||||
{"id": "1", "webVideoUrl": "https://tiktok.com/@a/video/1"},
|
||||
{"errorCode": "no_items", "input": "explore", "error": "empty"},
|
||||
]
|
||||
)
|
||||
assert len(out.items) == 2
|
||||
assert out.billable_units == 1
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""``tiktok.user_search`` executor: verb input → search args → typed profile items.
|
||||
|
||||
Boundary mocked: the proprietary search actor (injected fake). NOT mocked: the
|
||||
verb's own payload→args forwarding and the dict→TikTokProfileItem wrapping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.capabilities.tiktok.user_search.executor import build_user_search_executor
|
||||
from app.capabilities.tiktok.user_search.schemas import (
|
||||
UserSearchInput,
|
||||
UserSearchOutput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeSearch:
|
||||
"""Records the queries + kwargs it was called with; returns canned items."""
|
||||
|
||||
def __init__(self, items: list[dict]):
|
||||
self._items = items
|
||||
self.calls: list[tuple[list[str], int, int | None]] = []
|
||||
|
||||
async def __call__(
|
||||
self, queries: list[str], *, per_query: int, limit: int | None = None
|
||||
) -> list[dict]:
|
||||
self.calls.append((queries, per_query, limit))
|
||||
return self._items
|
||||
|
||||
|
||||
async def test_forwards_queries_and_limits_and_wraps_items():
|
||||
search = _FakeSearch([{"id": "1", "name": "nasa"}])
|
||||
execute = build_user_search_executor(search_fn=search)
|
||||
|
||||
out = await execute(
|
||||
UserSearchInput(queries=["nasa"], results_per_query=7, max_items=25)
|
||||
)
|
||||
|
||||
assert isinstance(out, UserSearchOutput)
|
||||
assert len(out.items) == 1
|
||||
assert out.items[0].name == "nasa"
|
||||
|
||||
(queries, per_query, limit) = search.calls[0]
|
||||
assert queries == ["nasa"]
|
||||
assert per_query == 7
|
||||
assert limit == 25
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""``tiktok.user_search`` input guards and billing: a query is required, bounded,
|
||||
and ErrorItems are surfaced free."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.capabilities.tiktok.scrape.schemas import MAX_TIKTOK_ITEMS, MAX_TIKTOK_SOURCES
|
||||
from app.capabilities.tiktok.user_search.schemas import (
|
||||
UserSearchInput,
|
||||
UserSearchOutput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_rejects_input_with_no_query():
|
||||
with pytest.raises(ValidationError):
|
||||
UserSearchInput(queries=[])
|
||||
|
||||
|
||||
def test_defaults_and_bounds():
|
||||
payload = UserSearchInput(queries=["nasa"])
|
||||
assert payload.max_items == 10
|
||||
assert payload.results_per_query == 10
|
||||
assert payload.estimated_units == 10
|
||||
with pytest.raises(ValidationError):
|
||||
UserSearchInput(queries=["nasa"], max_items=0)
|
||||
with pytest.raises(ValidationError):
|
||||
UserSearchInput(queries=["nasa"], max_items=MAX_TIKTOK_ITEMS + 1)
|
||||
|
||||
|
||||
def test_rejects_more_queries_than_the_cap():
|
||||
too_many = [f"q{i}" for i in range(MAX_TIKTOK_SOURCES + 1)]
|
||||
with pytest.raises(ValidationError):
|
||||
UserSearchInput(queries=too_many)
|
||||
|
||||
|
||||
def test_error_items_are_not_billed():
|
||||
# Real accounts count; ErrorItems (empty/withheld queries) are surfaced free.
|
||||
out = UserSearchOutput(
|
||||
items=[
|
||||
{"id": "1", "name": "nasa"},
|
||||
{"errorCode": "no_users", "input": "ghost", "error": "empty"},
|
||||
]
|
||||
)
|
||||
assert len(out.items) == 2
|
||||
assert out.billable_units == 1
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""Comments orchestration over a fake fetch (no network).
|
||||
|
||||
Drives ``scrape_tiktok_comments``: video URLs -> captured raw comments -> items.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.proprietary.platforms.tiktok import scrape_tiktok_comments
|
||||
|
||||
_VIDEO = "https://www.tiktok.com/@bob/video/123"
|
||||
|
||||
|
||||
def _comment(cid: str, reply_id: str = "0") -> dict[str, Any]:
|
||||
return {
|
||||
"cid": cid,
|
||||
"text": f"comment {cid}",
|
||||
"digg_count": 7,
|
||||
"reply_comment_total": 2,
|
||||
"create_time": 1700000000,
|
||||
"reply_id": reply_id,
|
||||
"user": {
|
||||
"uid": "u1",
|
||||
"unique_id": "alice",
|
||||
"nickname": "Alice",
|
||||
"avatar_thumb": {"url_list": ["https://cdn/a.webp"]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_comments_parse_dedupe_and_cap():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_comment("1"), _comment("1"), _comment("2", reply_id="1")]
|
||||
|
||||
items = await scrape_tiktok_comments(
|
||||
[_VIDEO], per_video=2, fetch_comments_fn=fake_fetch
|
||||
)
|
||||
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
first = items[0]
|
||||
assert first["text"] == "comment 1"
|
||||
assert first["videoWebUrl"] == _VIDEO
|
||||
assert first["diggCount"] == 7
|
||||
assert first["uniqueId"] == "alice"
|
||||
assert first["avatar"] == "https://cdn/a.webp"
|
||||
assert first["createTimeISO"] is not None
|
||||
assert first["repliesToId"] is None # reply_id "0" == top-level
|
||||
assert first["scrapedAt"] is not None
|
||||
assert items[1]["repliesToId"] == "1" # a reply carries its parent id
|
||||
|
||||
|
||||
async def test_empty_video_emits_error_item():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await scrape_tiktok_comments(
|
||||
[_VIDEO], per_video=5, fetch_comments_fn=fake_fetch
|
||||
)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "no_comments"
|
||||
assert items[0]["input"] == "123"
|
||||
|
||||
|
||||
async def test_non_video_url_emits_bad_url_error():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
raise AssertionError("should not fetch for a non-video URL")
|
||||
|
||||
items = await scrape_tiktok_comments(
|
||||
["https://www.tiktok.com/@bob"], per_video=5, fetch_comments_fn=fake_fetch
|
||||
)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "bad_url"
|
||||
|
||||
|
||||
async def test_comments_honor_limit_across_videos():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_comment("1"), _comment("2")]
|
||||
|
||||
items = await scrape_tiktok_comments(
|
||||
[_VIDEO, "https://www.tiktok.com/@bob/video/456"],
|
||||
per_video=5,
|
||||
limit=3,
|
||||
fetch_comments_fn=fake_fetch,
|
||||
)
|
||||
|
||||
assert len(items) == 3
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
"""Fetch-seam resilience for the TikTok scraper (no network, fake sessions).
|
||||
|
||||
Fake sessions drive the cookie warm-up + rotate-on-block + backoff branches
|
||||
deterministically; a live first IP normally warms and returns 200s.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.session import (
|
||||
TikTokAccessBlockedError,
|
||||
client,
|
||||
)
|
||||
from app.proprietary.platforms.tiktok.session.proxy import _current_session
|
||||
|
||||
_HTML = "<html><body>ok</body></html>"
|
||||
|
||||
|
||||
class _FakePage:
|
||||
def __init__(self, status: int, *, cookies: dict | None = None, body: str = _HTML):
|
||||
self.status = status
|
||||
self.cookies = cookies or {}
|
||||
self.body = body
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.body
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""One 'IP': homepage warm mints ``ttwid`` per flag; page GETs return ``status``."""
|
||||
|
||||
def __init__(self, status: int = 200, *, warms: bool = True, body: str = _HTML):
|
||||
self.status = status
|
||||
self.warms = warms
|
||||
self.body = body
|
||||
self.page_calls = 0
|
||||
self.warm_calls = 0
|
||||
|
||||
async def get(self, url, headers=None, cookies=None):
|
||||
if url.rstrip("/") == "https://www.tiktok.com":
|
||||
self.warm_calls += 1
|
||||
return _FakePage(200, cookies={"ttwid": "x"} if self.warms else {})
|
||||
self.page_calls += 1
|
||||
return _FakePage(self.status, body=self.body)
|
||||
|
||||
|
||||
class _FakeHolder:
|
||||
def __init__(self, sessions: list[_FakeSession]) -> None:
|
||||
self._sessions = sessions
|
||||
self.session = sessions[0]
|
||||
self.rotations = 0
|
||||
self.warmed = False
|
||||
|
||||
async def rotate(self):
|
||||
self.rotations += 1
|
||||
self.session = self._sessions[min(self.rotations, len(self._sessions) - 1)]
|
||||
self.warmed = False
|
||||
return self.session
|
||||
|
||||
async def pace(self) -> None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _no_sleep(monkeypatch) -> None:
|
||||
async def _noop(_seconds):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(client.asyncio, "sleep", _noop)
|
||||
|
||||
|
||||
async def test_warms_then_returns_html():
|
||||
holder = _FakeHolder([_FakeSession(200, warms=True)])
|
||||
token = _current_session.set(holder)
|
||||
try:
|
||||
result = await client.fetch_html("https://www.tiktok.com/@scout2015")
|
||||
finally:
|
||||
_current_session.reset(token)
|
||||
assert result == _HTML
|
||||
assert holder.rotations == 0
|
||||
assert holder.session.warm_calls == 1
|
||||
|
||||
|
||||
async def test_rotates_when_warm_fails_then_succeeds():
|
||||
holder = _FakeHolder([_FakeSession(200, warms=False), _FakeSession(200, warms=True)])
|
||||
token = _current_session.set(holder)
|
||||
try:
|
||||
result = await client.fetch_html("https://www.tiktok.com/@scout2015")
|
||||
finally:
|
||||
_current_session.reset(token)
|
||||
assert result == _HTML
|
||||
assert holder.rotations == 1
|
||||
|
||||
|
||||
async def test_404_returns_none_without_rotating():
|
||||
holder = _FakeHolder([_FakeSession(404), _FakeSession(200)])
|
||||
token = _current_session.set(holder)
|
||||
try:
|
||||
result = await client.fetch_html("https://www.tiktok.com/@missing")
|
||||
finally:
|
||||
_current_session.reset(token)
|
||||
assert result is None
|
||||
assert holder.rotations == 0
|
||||
|
||||
|
||||
async def test_rotates_and_rewarms_on_403():
|
||||
holder = _FakeHolder([_FakeSession(403), _FakeSession(200, warms=True)])
|
||||
token = _current_session.set(holder)
|
||||
try:
|
||||
result = await client.fetch_html("https://www.tiktok.com/@scout2015")
|
||||
finally:
|
||||
_current_session.reset(token)
|
||||
assert result == _HTML
|
||||
assert holder.rotations == 1
|
||||
assert holder.session.warm_calls == 1
|
||||
|
||||
|
||||
async def test_persistent_403_raises_blocked(monkeypatch):
|
||||
_no_sleep(monkeypatch)
|
||||
holder = _FakeHolder(
|
||||
[_FakeSession(403) for _ in range(client._MAX_ROTATIONS + 1)]
|
||||
)
|
||||
token = _current_session.set(holder)
|
||||
try:
|
||||
raised = False
|
||||
try:
|
||||
await client.fetch_html("https://www.tiktok.com/@scout2015")
|
||||
except TikTokAccessBlockedError:
|
||||
raised = True
|
||||
finally:
|
||||
_current_session.reset(token)
|
||||
assert raised
|
||||
assert holder.rotations == client._MAX_ROTATIONS
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""Rehydration-blob extraction from TikTok page HTML (pure, no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.extraction import extract_rehydration_data
|
||||
|
||||
_BLOB = (
|
||||
'<html><body><script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" '
|
||||
'type="application/json">'
|
||||
'{"__DEFAULT_SCOPE__":{"webapp.video-detail":{"itemInfo":{"itemStruct":'
|
||||
'{"id":"123"}}}}}'
|
||||
"</script></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def test_extracts_default_scope_from_blob():
|
||||
data = extract_rehydration_data(_BLOB)
|
||||
assert data is not None
|
||||
item = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
|
||||
assert item["id"] == "123"
|
||||
|
||||
|
||||
def test_returns_none_when_blob_absent():
|
||||
assert extract_rehydration_data("<html><body>no blob here</body></html>") is None
|
||||
|
||||
|
||||
def test_returns_none_when_blob_json_malformed():
|
||||
broken = (
|
||||
'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" '
|
||||
'type="application/json">{not json}</script>'
|
||||
)
|
||||
assert extract_rehydration_data(broken) is None
|
||||
26
surfsense_backend/tests/unit/platforms/tiktok/test_input.py
Normal file
26
surfsense_backend/tests/unit/platforms/tiktok/test_input.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Input surface for the TikTok scraper (anonymous, Apify-shaped)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.schemas import TikTokScrapeInput
|
||||
|
||||
|
||||
def test_input_has_no_auth_fields():
|
||||
forbidden = {"username", "password", "token", "login", "auth", "credentials"}
|
||||
assert forbidden.isdisjoint(TikTokScrapeInput.model_fields)
|
||||
|
||||
|
||||
def test_input_defaults():
|
||||
model = TikTokScrapeInput()
|
||||
assert model.resultsPerPage == 1
|
||||
assert model.profileSorting == "latest"
|
||||
assert model.proxyCountryCode == "None"
|
||||
assert model.hashtags == []
|
||||
assert model.profiles == []
|
||||
assert model.searchQueries == []
|
||||
assert model.postURLs == []
|
||||
|
||||
|
||||
def test_input_allows_extra_inert_fields():
|
||||
model = TikTokScrapeInput(shouldDownloadVideos=True, videoKvStoreIdOrName="x")
|
||||
assert model.model_dump().get("shouldDownloadVideos") is True
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""Pulling item structs out of captured item_list / search API responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.extraction import items_from_response
|
||||
|
||||
|
||||
def test_reads_item_list_shape():
|
||||
body = {"itemList": [{"id": "1"}, {"id": "2"}], "hasMore": True}
|
||||
assert items_from_response(body) == [{"id": "1"}, {"id": "2"}]
|
||||
|
||||
|
||||
def test_reads_search_data_shape():
|
||||
body = {"data": [{"type": 1, "item": {"id": "9"}}, {"type": 4, "item": {}}]}
|
||||
assert items_from_response(body) == [{"id": "9"}, {}]
|
||||
|
||||
|
||||
def test_skips_malformed_entries():
|
||||
body = {"data": [{"type": 1}, "junk", {"item": {"id": "7"}}]}
|
||||
assert items_from_response(body) == [{"id": "7"}]
|
||||
|
||||
|
||||
def test_returns_empty_for_unrelated_json():
|
||||
assert items_from_response({"statusCode": 0}) == []
|
||||
assert items_from_response("nope") == []
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"""Retry-on-empty for the browser ``item_list`` seam (no browser, fake fetch).
|
||||
|
||||
``fetch_item_list`` re-fetches an empty capture up to ``TIKTOK_LISTING_MAX_ATTEMPTS``
|
||||
so a flagged rotating exit IP on the first draw doesn't collapse straight to an
|
||||
``ErrorItem``. These drive that loop deterministically by faking ``_fetch_sync``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.session import listing
|
||||
|
||||
|
||||
class _Fake:
|
||||
"""Returns each queued result once, repeating the last; counts calls."""
|
||||
|
||||
def __init__(self, results: list[list[dict]]):
|
||||
self.results = results
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
out = self.results[min(self.calls, len(self.results) - 1)]
|
||||
self.calls += 1
|
||||
return out
|
||||
|
||||
|
||||
def _patch(monkeypatch, fake: _Fake, attempts: int) -> None:
|
||||
monkeypatch.setattr(listing, "_fetch_sync", fake)
|
||||
monkeypatch.setattr(listing.config, "TIKTOK_LISTING_MAX_ATTEMPTS", attempts)
|
||||
|
||||
|
||||
async def test_returns_first_nonempty_without_retrying(monkeypatch):
|
||||
fake = _Fake([[{"id": "1"}]])
|
||||
_patch(monkeypatch, fake, 3)
|
||||
items = await listing.fetch_item_list("https://tt/@x", 5)
|
||||
assert items == [{"id": "1"}]
|
||||
assert fake.calls == 1 # a draw with items never retries
|
||||
|
||||
|
||||
async def test_retries_past_empty_draws_then_hits(monkeypatch):
|
||||
fake = _Fake([[], [], [{"id": "9"}]])
|
||||
_patch(monkeypatch, fake, 3)
|
||||
items = await listing.fetch_item_list("https://tt/@x", 5)
|
||||
assert items == [{"id": "9"}]
|
||||
assert fake.calls == 3 # two empty (flagged-IP) draws retried, third lands
|
||||
|
||||
|
||||
async def test_stops_at_attempt_ceiling_when_always_empty(monkeypatch):
|
||||
fake = _Fake([[]])
|
||||
_patch(monkeypatch, fake, 3)
|
||||
items = await listing.fetch_item_list("https://tt/@x", 5)
|
||||
assert items == []
|
||||
assert fake.calls == 3 # capped; caller then emits the ErrorItem
|
||||
|
||||
|
||||
async def test_single_attempt_config_disables_retry(monkeypatch):
|
||||
fake = _Fake([[]])
|
||||
_patch(monkeypatch, fake, 1)
|
||||
items = await listing.fetch_item_list("https://tt/@x", 5)
|
||||
assert items == []
|
||||
assert fake.calls == 1 # static-IP setups opt out via attempts=1
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"""End-to-end orchestration over a fake fetch (no network).
|
||||
|
||||
Drives the public collector: input -> target -> blob-first flow -> items.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.proprietary.platforms.tiktok import TikTokScrapeInput, scrape_tiktok
|
||||
|
||||
|
||||
async def _no_html(_url: str) -> str:
|
||||
"""Fetch stub that yields no rehydration blob (skips profile metadata)."""
|
||||
return ""
|
||||
|
||||
|
||||
def _profile_page(username: str, followers: int, videos: int) -> str:
|
||||
blob = {
|
||||
"__DEFAULT_SCOPE__": {
|
||||
"webapp.user-detail": {
|
||||
"userInfo": {
|
||||
"user": {
|
||||
"id": "u1",
|
||||
"uniqueId": username,
|
||||
"nickname": "Nick",
|
||||
"verified": True,
|
||||
},
|
||||
"stats": {"followerCount": followers, "videoCount": videos},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" '
|
||||
f'type="application/json">{json.dumps(blob)}</script>'
|
||||
)
|
||||
|
||||
|
||||
def _video_page(video_id: str, username: str) -> str:
|
||||
blob = {
|
||||
"__DEFAULT_SCOPE__": {
|
||||
"webapp.video-detail": {
|
||||
"itemInfo": {
|
||||
"itemStruct": {
|
||||
"id": video_id,
|
||||
"desc": "hello",
|
||||
"author": {"uniqueId": username},
|
||||
"stats": {"diggCount": 5},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" '
|
||||
f'type="application/json">{json.dumps(blob)}</script>'
|
||||
)
|
||||
|
||||
|
||||
async def test_scrape_video_url_returns_parsed_item():
|
||||
url = "https://www.tiktok.com/@scout2015/video/123"
|
||||
|
||||
async def fake_fetch(_url: str) -> str:
|
||||
return _video_page("123", "scout2015")
|
||||
|
||||
items = await scrape_tiktok(TikTokScrapeInput(postURLs=[url]), fetch=fake_fetch)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["id"] == "123"
|
||||
assert items[0]["diggCount"] == 5
|
||||
assert items[0]["webVideoUrl"] == "https://www.tiktok.com/@scout2015/video/123"
|
||||
assert items[0]["scrapedAt"] is not None
|
||||
|
||||
|
||||
async def test_scrape_honors_limit_across_targets():
|
||||
urls = [
|
||||
"https://www.tiktok.com/@a/video/1",
|
||||
"https://www.tiktok.com/@b/video/2",
|
||||
]
|
||||
|
||||
async def fake_fetch(url: str) -> str:
|
||||
vid = url.rsplit("/", 1)[1]
|
||||
user = url.split("@")[1].split("/")[0]
|
||||
return _video_page(vid, user)
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(postURLs=urls), limit=1, fetch=fake_fetch
|
||||
)
|
||||
assert len(items) == 1
|
||||
|
||||
|
||||
async def test_scrape_skips_unrecognized_urls():
|
||||
async def fake_fetch(_url: str) -> str:
|
||||
return ""
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(postURLs=["https://example.com/x"]), fetch=fake_fetch
|
||||
)
|
||||
assert items == []
|
||||
|
||||
|
||||
async def test_scrape_profile_returns_listing_items():
|
||||
async def fake_listing(_url: str, _count: int) -> list[dict]:
|
||||
return [
|
||||
{"id": "1", "author": {"uniqueId": "a"}},
|
||||
{"id": "2", "author": {"uniqueId": "a"}},
|
||||
]
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(profiles=["a"], resultsPerPage=5),
|
||||
fetch=_no_html,
|
||||
fetch_listing=fake_listing,
|
||||
)
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
assert items[0]["webVideoUrl"] == "https://www.tiktok.com/@a/video/1"
|
||||
|
||||
|
||||
async def test_profile_emits_metadata_then_videos():
|
||||
# The blob metadata item comes first and is billable; videos follow.
|
||||
async def fake_fetch(_url: str) -> str:
|
||||
return _profile_page("a", followers=100, videos=2)
|
||||
|
||||
async def fake_listing(_url: str, _count: int) -> list[dict]:
|
||||
return [{"id": "1", "author": {"uniqueId": "a"}}]
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(profiles=["a"], resultsPerPage=5),
|
||||
fetch=fake_fetch,
|
||||
fetch_listing=fake_listing,
|
||||
)
|
||||
assert len(items) == 2
|
||||
profile, video = items
|
||||
assert "webVideoUrl" not in profile # metadata item, not a video
|
||||
assert profile["name"] == "a"
|
||||
assert profile["fans"] == 100
|
||||
assert profile["verified"] is True
|
||||
assert profile["scrapedAt"] is not None
|
||||
assert video["id"] == "1"
|
||||
|
||||
|
||||
async def test_profile_metadata_survives_blocked_listing():
|
||||
# Videos withheld from anonymous access: we still return the profile metadata
|
||||
# (not just an ErrorItem), so a blocked profile isn't a total loss.
|
||||
async def fake_fetch(_url: str) -> str:
|
||||
return _profile_page("a", followers=100, videos=9)
|
||||
|
||||
async def fake_listing(_url: str, _count: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(profiles=["a"], resultsPerPage=5),
|
||||
fetch=fake_fetch,
|
||||
fetch_listing=fake_listing,
|
||||
)
|
||||
assert len(items) == 2
|
||||
assert items[0]["name"] == "a"
|
||||
assert items[0]["fans"] == 100
|
||||
assert items[1]["errorCode"] == "no_items"
|
||||
|
||||
|
||||
async def test_listing_dedupes_then_caps_per_target():
|
||||
async def fake_listing(_url: str, _count: int) -> list[dict]:
|
||||
return [{"id": "1"}, {"id": "1"}, {"id": "2"}, {"id": "3"}]
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(hashtags=["x"], resultsPerPage=2), fetch_listing=fake_listing
|
||||
)
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
|
||||
|
||||
async def test_empty_listing_emits_error_item():
|
||||
# A trust-gated/empty feed (0 videos) must surface one honest ErrorItem,
|
||||
# tagged with errorCode, rather than vanishing silently.
|
||||
async def fake_listing(_url: str, _count: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await scrape_tiktok(
|
||||
TikTokScrapeInput(profiles=["nasa"], resultsPerPage=5),
|
||||
fetch=_no_html,
|
||||
fetch_listing=fake_listing,
|
||||
)
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "no_items"
|
||||
assert items[0]["input"] == "nasa"
|
||||
102
surfsense_backend/tests/unit/platforms/tiktok/test_parsers.py
Normal file
102
surfsense_backend/tests/unit/platforms/tiktok/test_parsers.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Raw TikTok payload -> normalized item mapping (pure, no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.extraction import parse_author, parse_video
|
||||
|
||||
_ITEM_STRUCT = {
|
||||
"id": "7534061113365859586",
|
||||
"desc": "haha #comeramabanana",
|
||||
"createTime": 1_700_000_000,
|
||||
"author": {
|
||||
"id": "6733",
|
||||
"uniqueId": "bruniela_",
|
||||
"nickname": "Bruni",
|
||||
"verified": False,
|
||||
"signature": "bio here",
|
||||
"avatarLarger": "https://cdn/avatar.jpg",
|
||||
},
|
||||
"authorStats": {
|
||||
"followerCount": 51200,
|
||||
"followingCount": 269,
|
||||
"heartCount": 3_000_000,
|
||||
"videoCount": 259,
|
||||
},
|
||||
"stats": {
|
||||
"diggCount": 5344,
|
||||
"shareCount": 701,
|
||||
"playCount": 55700,
|
||||
"commentCount": 24,
|
||||
"collectCount": 291,
|
||||
},
|
||||
"music": {
|
||||
"id": "7529",
|
||||
"title": "som original",
|
||||
"authorName": "fox_rus0",
|
||||
"original": True,
|
||||
"playUrl": "https://cdn/music.mp3",
|
||||
},
|
||||
"video": {
|
||||
"height": 1024,
|
||||
"width": 576,
|
||||
"duration": 16,
|
||||
"cover": "https://cdn/cover.jpg",
|
||||
"format": "mp4",
|
||||
"definition": "540p",
|
||||
},
|
||||
"challenges": [{"id": "4982299", "title": "comeramabanana"}],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_video_maps_core_and_derived_fields():
|
||||
item = parse_video(_ITEM_STRUCT)
|
||||
|
||||
assert item["id"] == "7534061113365859586"
|
||||
assert item["text"] == "haha #comeramabanana"
|
||||
assert item["createTimeISO"] == "2023-11-14T22:13:20.000Z"
|
||||
|
||||
assert item["authorMeta"]["name"] == "bruniela_"
|
||||
assert item["authorMeta"]["nickName"] == "Bruni"
|
||||
assert item["authorMeta"]["profileUrl"] == "https://www.tiktok.com/@bruniela_"
|
||||
assert item["authorMeta"]["fans"] == 51200
|
||||
|
||||
assert item["musicMeta"]["musicName"] == "som original"
|
||||
assert item["videoMeta"]["duration"] == 16
|
||||
|
||||
assert item["diggCount"] == 5344
|
||||
assert item["playCount"] == 55700
|
||||
|
||||
assert item["hashtags"] == [{"id": "4982299", "name": "comeramabanana"}]
|
||||
assert (
|
||||
item["webVideoUrl"]
|
||||
== "https://www.tiktok.com/@bruniela_/video/7534061113365859586"
|
||||
)
|
||||
|
||||
|
||||
_USER_INFO = {
|
||||
"user": {
|
||||
"id": "6733",
|
||||
"uniqueId": "bruniela_",
|
||||
"nickname": "Bruni",
|
||||
"verified": True,
|
||||
"signature": "bio here",
|
||||
"avatarLarger": "https://cdn/avatar.jpg",
|
||||
"privateAccount": False,
|
||||
},
|
||||
"stats": {
|
||||
"followerCount": 51200,
|
||||
"followingCount": 269,
|
||||
"heartCount": 3_000_000,
|
||||
"videoCount": 259,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_author_maps_user_and_stats():
|
||||
author = parse_author(_USER_INFO)
|
||||
assert author["name"] == "bruniela_"
|
||||
assert author["nickName"] == "Bruni"
|
||||
assert author["verified"] is True
|
||||
assert author["profileUrl"] == "https://www.tiktok.com/@bruniela_"
|
||||
assert author["fans"] == 51200
|
||||
assert author["video"] == 259
|
||||
30
surfsense_backend/tests/unit/platforms/tiktok/test_scopes.py
Normal file
30
surfsense_backend/tests/unit/platforms/tiktok/test_scopes.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Navigating the rehydration blob to its useful scopes (pure, no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.extraction import user_info, video_item_struct
|
||||
|
||||
|
||||
def test_video_item_struct_navigates_video_detail_scope():
|
||||
data = {
|
||||
"__DEFAULT_SCOPE__": {
|
||||
"webapp.video-detail": {"itemInfo": {"itemStruct": {"id": "123"}}}
|
||||
}
|
||||
}
|
||||
item = video_item_struct(data)
|
||||
assert item == {"id": "123"}
|
||||
|
||||
|
||||
def test_user_info_navigates_user_detail_scope():
|
||||
data = {
|
||||
"__DEFAULT_SCOPE__": {
|
||||
"webapp.user-detail": {"userInfo": {"user": {"uniqueId": "scout2015"}}}
|
||||
}
|
||||
}
|
||||
info = user_info(data)
|
||||
assert info == {"user": {"uniqueId": "scout2015"}}
|
||||
|
||||
|
||||
def test_scopes_return_none_when_absent():
|
||||
assert video_item_struct({}) is None
|
||||
assert user_info({"__DEFAULT_SCOPE__": {}}) is None
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""URL classification for the TikTok scraper (pure, no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok.targets import resolve_target
|
||||
|
||||
|
||||
def test_resolve_video_carries_username_and_id():
|
||||
target = resolve_target("https://www.tiktok.com/@scout2015/video/6718335390845095173")
|
||||
assert target is not None
|
||||
assert target.kind == "video"
|
||||
assert target.value == "6718335390845095173"
|
||||
assert target.username == "scout2015"
|
||||
|
||||
|
||||
def test_resolve_profile():
|
||||
target = resolve_target("https://www.tiktok.com/@scout2015")
|
||||
assert target is not None
|
||||
assert target.kind == "profile"
|
||||
assert target.value == "scout2015"
|
||||
|
||||
|
||||
def test_resolve_hashtag():
|
||||
target = resolve_target("https://www.tiktok.com/tag/funny")
|
||||
assert target is not None
|
||||
assert target.kind == "hashtag"
|
||||
assert target.value == "funny"
|
||||
|
||||
|
||||
def test_resolve_search_top_video_and_user_sections():
|
||||
top = resolve_target("https://www.tiktok.com/search?q=cats")
|
||||
assert top is not None
|
||||
assert top.kind == "search"
|
||||
assert top.value == "cats"
|
||||
assert top.section is None
|
||||
|
||||
videos = resolve_target("https://www.tiktok.com/search/video?q=cats")
|
||||
assert videos is not None and videos.section == "video"
|
||||
|
||||
users = resolve_target("https://www.tiktok.com/search/user?q=cats")
|
||||
assert users is not None and users.section == "user"
|
||||
|
||||
|
||||
def test_resolve_rejects_non_tiktok_and_unknown_paths():
|
||||
assert resolve_target("https://example.com/@scout2015") is None
|
||||
assert resolve_target("https://www.tiktok.com/") is None
|
||||
assert resolve_target("https://www.tiktok.com/foundation") is None
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""Trending orchestration over a fake fetch (no network).
|
||||
|
||||
Drives ``scrape_tiktok_trending``: the Explore feed -> captured itemStructs ->
|
||||
video items, reusing the listing flow (parse/dedupe/cap/empty-ErrorItem).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.proprietary.platforms.tiktok import scrape_tiktok_trending
|
||||
|
||||
|
||||
async def test_trending_parses_dedupes_and_caps():
|
||||
async def fake_fetch(url: str, _cap: int) -> list[dict]:
|
||||
assert url == "https://www.tiktok.com/explore"
|
||||
return [
|
||||
{"id": "1", "author": {"uniqueId": "a"}},
|
||||
{"id": "1", "author": {"uniqueId": "a"}},
|
||||
{"id": "2", "author": {"uniqueId": "b"}},
|
||||
]
|
||||
|
||||
items = await scrape_tiktok_trending(count=2, fetch_trending_fn=fake_fetch)
|
||||
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
assert items[0]["webVideoUrl"] == "https://www.tiktok.com/@a/video/1"
|
||||
assert items[0]["scrapedAt"] is not None
|
||||
|
||||
|
||||
async def test_trending_empty_feed_emits_error_item():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await scrape_tiktok_trending(count=5, fetch_trending_fn=fake_fetch)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "no_items"
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""User-search orchestration over a fake fetch (no network).
|
||||
|
||||
Drives ``search_tiktok_users``: queries -> captured ``user_info`` -> profile items.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.proprietary.platforms.tiktok import search_tiktok_users
|
||||
|
||||
|
||||
def _user(uid: str, unique_id: str, followers: int = 10) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": uid,
|
||||
"unique_id": unique_id,
|
||||
"nickname": unique_id.upper(),
|
||||
"signature": "bio",
|
||||
"follower_count": followers,
|
||||
"total_favorited": 999,
|
||||
"sec_uid": f"sec-{uid}",
|
||||
"enterprise_verify_reason": "official" if uid == "1" else "",
|
||||
"avatar_thumb": {"url_list": [f"https://cdn/{uid}.webp"]},
|
||||
}
|
||||
|
||||
|
||||
async def test_user_search_parses_dedupes_and_caps():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_user("1", "nasa"), _user("1", "nasa"), _user("2", "nasa2")]
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["nasa"], per_query=2, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
assert [i["id"] for i in items] == ["1", "2"]
|
||||
first = items[0]
|
||||
assert first["name"] == "nasa"
|
||||
assert first["nickName"] == "NASA"
|
||||
assert first["profileUrl"] == "https://www.tiktok.com/@nasa"
|
||||
assert first["verified"] is True
|
||||
assert first["fans"] == 10
|
||||
assert first["avatar"] == "https://cdn/1.webp"
|
||||
assert first["secUid"] == "sec-1"
|
||||
assert first["scrapedAt"] is not None
|
||||
assert items[1]["verified"] is False
|
||||
|
||||
|
||||
async def test_user_search_empty_query_emits_error_item():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["ghost"], per_query=5, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]["errorCode"] == "no_users"
|
||||
assert items[0]["input"] == "ghost"
|
||||
|
||||
|
||||
async def test_user_search_honors_limit_across_queries():
|
||||
async def fake_fetch(_url: str, _cap: int) -> list[dict]:
|
||||
return [_user("1", "a"), _user("2", "b")]
|
||||
|
||||
items = await search_tiktok_users(
|
||||
["q1", "q2"], per_query=5, limit=3, fetch_users=fake_fetch
|
||||
)
|
||||
|
||||
# 2 from q1 + 1 from q2, then the cross-query limit stops it.
|
||||
assert len(items) == 3
|
||||
Loading…
Add table
Add a link
Reference in a new issue