tests/test_metadata_fc2.py
Rama master · réplica de lectura del repositorio original.
"""FC2 五站:发行方商品页、fc2cmadb 镜像页、FC2PPV-DB、JAVten 与 JavArchive 转载页的取页与解析。"""
import json
import unittest
import urllib.parse
from peach.http import HttpResponse
from peach.sources import FailureReason, Page, Session, SourceFailure
from peach.sources.fc2 import FC2, STUDIO, UNRECOGNISED, Fc2Source, canonical_code, runtime_minutes, video_id
from peach.sources.fc2cmadb import COMPONENT as MIRROR_COMPONENT
from peach.sources.fc2cmadb import FC2CMADB, Fc2cmadbSource, parse_actresses, partial_headers
from peach.sources.fc2ppvdb import FC2PPVDB, Fc2ppvdbSource, japanese_date
from peach.sources.javarchive import JAVARCHIVE, JavArchiveSource, links
from peach.sources import javten as javten_module
from peach.sources.javten import JAVTEN, JavtenSource
CHALLENGE = ('<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title></head>'
'<body><div id="challenge-body-text">Verifying you are human.</div></body></html>')
#: 站上那份前端资源的指纹,2026-09-22 实测形态。
MIRROR_VERSION = "fcb3b524d4c7f8f3d2c38e437b35b7a9"
COVER = "https://storage92000.contents.fc2.com/file/261/26076760/1711813737.76.jpg"
TITLE = "みお(19)可愛い巨乳JDの初アナル貫通動画"
def shop_page(video="4364209", title=TITLE, image=COVER, slug="otonakamenz",
seller="大人仮面Z", sold="2024/03/31", runtime="41:50",
tags=("おっぱい", "お尻", "素人")):
"""商品页里解析用得上的那几块,标记与站上一致。"""
product = {"@type": "Product", "@id": f"article:{video}", "sku": video, "name": title,
"description": "本編と別アングルの2本立て。",
"image": {"url": image, "@type": "ImageObject"},
"brand": {"name": None, "url": f"https://adult.contents.fc2.com/users/{slug}/",
"@type": "Brand"}}
links = "".join(f'<a class="tag tagTag" href="/search/?tag=x">{tag}</a>' for tag in tags)
return (
'<script type="application/ld+json">{"@type": "BreadcrumbList"}</script>'
f'<script type="application/ld+json">{json.dumps(product, ensure_ascii=False)}</script>'
f'<div class="items_article_headerInfo"><h3>{title}</h3>'
f'<a href="https://adult.contents.fc2.com/users/{slug}/">{seller}</a></div>'
f'<p class="items_article_info">{runtime}</p>'
f'<div class="items_article_Releasedate"><p>販売日 : {sold}</p></div>'
f'<section class="items_article_TagArea"><h3>商品タグ</h3>{links}</section>'
)
def gone_page():
"""下架后站上仍回 200,只是正文里那份 Product 不见了。"""
return ('<script type="application/ld+json">{"@type": "BreadcrumbList"}</script>'
'<div class="items_notfound"><h3>見つかりませんでした</h3></div>')
ARCHIVE_LINK = "/926949-FC2-PPV-4137487-Gカップ・脅威-マジ凄いです!脅威のパイパーGカップグラマラス!-pn.html"
ARCHIVE_TITLE = "Gカップ・脅威 マジ凄いです!脅威のパイパーGカップグラマラス!"
ARCHIVE_PICTURES = "https://img.javstore.net/images/2023/12/26/"
def search_page(video="4137487", picture=True, reposted=False):
"""搜索结果。侧栏的本周热门与归档菜单用的是同一种地址形状,所以它们也在这一页里。
第二条那部没有图,只有标题链接——站上实测一半的结果是这样(`1863914`、`2110084`)。
`reposted` 是同一个商品的第二条:不同转存者各发一次,文章号、番号写法和标题都不同。
"""
heading = f"FC2-PPV-{video} {ARCHIVE_TITLE}"
thumb = (f'<img src="{ARCHIVE_PICTURES}{video}ps.jpg" alt="{heading}" loading="lazy">'
if picture else "")
repost = (f'<li><a href="/800025-fc2ppv-{video}-離婚の後遺症で性欲が止まらない変態女-pn.html" '
f'title="FC2PPV {video} 離婚の後遺症">转存的那一条</a></li>' if reposted else "")
return (
'<ul><li><a href="/1000-featured-articles-cn.html">Featured</a></li>'
'<li><a href="/4-av-censored-cn.html">AV Censored</a></li></ul>'
'<div class="news_1n"><ul><li>'
f'<a href="/926949-FC2-PPV-{video}-Gカップ・脅威-マジ凄いです!脅威のパイパーGカップグラマラス!-pn.html" '
f'title="{heading}">{thumb}</a>'
'</li><li>'
'<a href="/735702-fc2-ppv-1863914-10代美少女、圧倒的透明感のしほちゃん。-pn.html" '
'title="FC2 PPV 1863914 10代美少女、圧倒的透明感のしほちゃん。">另一部</a>'
f"</li>{repost}</ul></div>"
)
def archive_page(video="4137487", title=None, large="{root}{video}pl.jpg",
small="{root}{video}ps.jpg", tags="ハメ撮り|素人|爆乳", release="2023/11/21",
runtime="50:03"):
"""作品页。封面在两个图位上,标签、日期、时长在正文那块资料里,转存者可填可不填。
`<head>` 里那条 `description` 照站上的样子写:它把同一段话截断成 `…|S級...` 塞进
`content`,整页搜「标签:」会先命中它。余下几段是转载来的网盘链接,一概不取。
"""
heading = title if title is not None else f"FC2-PPV-{video} {ARCHIVE_TITLE}"
link = ARCHIVE_LINK if video == "4137487" else f"/926949-FC2-PPV-{video}-x-pn.html"
slots = []
if large:
slots.append('<div class="fisrst_sc">'
f'<img src="{large.format(root=ARCHIVE_PICTURES, video=video)}" '
f'alt="{heading}" /></div>')
if small:
slots.append(f'<img itemprop="image" src="{small.format(root=ARCHIVE_PICTURES, video=video)}"'
f' alt="{heading}">')
rows = "".join(f"{label}:{value} <br class='a5555' />" for label, value in
(("标签", tags), ("日期", release), ("时长", runtime)) if value)
return (
'<head><meta name="description" content="商品名称:FC2-PPV-'
f'{video} {ARCHIVE_TITLE} 标签:ハメ撮り|素人|S級..." /></head>'
f'<div class="menudd"><h1><a href="{link}" title="{heading}">{heading}</a></h1></div>'
f'<div class="news"><div class="first_des">{heading}</div>'
+ "".join(slots)
+ f"商品名称:{heading} <br class='a5555' />{rows}"
# 多帧拼成的长条预览,正文里标着 Preview,不是封面。
f'Original version: <a href="{ARCHIVE_PICTURES}ARCHIVE-FC2PPV-{video}_s.jpg">CLICK HERE!</a>'
f'<img src="{ARCHIVE_PICTURES}fc2ppv-{video}_s.jpg" alt="{heading}" />'
'<div class="downloads">https://rapidgator.net/file/deadbeef/x.mp4.html</div></div>'
)
def mirror_page(video="3189161", title="【無】コスプレシリーズ", image=COVER,
slug="rina_vlog", seller="梨奈の射精動画@個人撮影",
release="2023-02-19", duration="46:06", tags=("ハメ撮り", "フェラ"),
component=MIRROR_COMPONENT, version=MIRROR_VERSION):
"""fc2cmadb 是 Laravel + Inertia,整棵 props 树放在一个 script 里,正文是空壳。"""
page = {"component": component, "version": version, "url": f"/articles/{video}",
"props": {"appName": "FC2CMADB", "auth": {"user": None},
"article": {"id": 364910, "video_id": int(video), "title": title,
"release_date": release, "duration": duration,
"image_url": image,
"writer": {"id": 1818, "slug": slug, "name": seller},
"tags": [{"name": tag} for tag in tags]}}}
return (f'<script data-page="app" type="application/json">'
f'{json.dumps(page, ensure_ascii=False)}</script><div id="app"></div>')
def mirror_actresses(*named):
"""点名 `actresses` 那一跳回来的东西:整份 Inertia 响应,props 里只剩要的那一栏。"""
listed = [{"id": 3667 + at, "name": name, "description": None,
"alias_name": "ののみやすず 逢坂りの きたのあや", "redirect_url": None,
"pivot": {"article_id": 521612, "actress_id": 3667 + at}}
for at, name in enumerate(named)]
return json.dumps({"component": MIRROR_COMPONENT, "version": MIRROR_VERSION,
"url": "/articles/3189161",
"props": {"errors": {}, "actresses": listed}}, ensure_ascii=False)
def page(html, url="https://example.test/"):
"""站上回的是字节,解析器按 UTF-8 读。"""
return Page(url, html.encode() if isinstance(html, str) else html)
def article(html, code):
return Fc2Source().parse(page(html), code).payload()
def mirror(html, code):
return Fc2cmadbSource().parse(page(html), code).payload()
def archive(html, code):
return JavArchiveSource().parse(page(html), code).payload()
def serve(pages):
"""按地址回页面的假传输。键是解码后的地址;值可以是按请求头挑页面的函数。缺的回 404。"""
calls = []
def call(request, timeout, limit):
url = urllib.parse.unquote(request.url)
calls.append((url, request.headers.get("Referer"), limit, request.headers.get("X-Inertia-Partial-Data")))
body = pages.get(url)
if callable(body):
body = body(request.headers)
if isinstance(body, str):
body = body.encode()
return HttpResponse(200 if body is not None else 404, {}, body or b"", request.url)
call.calls = calls
return call
class Fc2CodeTests(unittest.TestCase):
def test_a_ledger_code_becomes_the_shop_number_in_every_writing_it_wears(self):
for code in ("FC2-PPV-4364209", "FC2PPV-4364209", "fc2_ppv_4364209",
"FC2 PPV 4364209", "FC2-4364209"):
self.assertEqual(video_id(code), "4364209", code)
self.assertEqual(canonical_code("4364209"), "FC2-PPV-4364209")
self.assertEqual(Fc2Source().article_url("FC2PPV-4364209"),
"https://adult.contents.fc2.com/article/4364209/")
self.assertEqual(Fc2cmadbSource().article_url("FC2PPV-4364209"), "https://fc2cmadb.com/articles/4364209")
def test_a_collection_part_asks_nowhere(self):
# `FC2-PPV-3312576-1` 是 21 段合集里的一段,两个站都只有整份合集那一页。
# 认出商品号就意味着把合集封面套给每一段,屏幕上是 21 个内容顶着同一张图。
for code in ("FC2-PPV-3312576-1", "FC2-PPV-3312576_4", "", "ABW-358"):
self.assertEqual(video_id(code), "", code)
self.assertEqual(Fc2Source().article_url(code), "", code)
self.assertEqual(Fc2cmadbSource().article_url(code), "", code)
self.assertEqual(JavArchiveSource().search_url(code), "", code)
def test_a_code_without_a_shop_number_fails_every_site_before_any_request(self):
for site in (Fc2Source(), Fc2cmadbSource(), JavArchiveSource()):
transport = serve({})
with self.subTest(site=site.config.name), self.assertRaises(SourceFailure) as caught:
site.records("FC2-PPV-3312576-1", session=Session(transport))
self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, UNRECOGNISED))
self.assertEqual(transport.calls, [])
def test_both_runtime_writings_read_as_minutes(self):
self.assertEqual(runtime_minutes("41:50"), 41.83)
self.assertEqual(runtime_minutes("1:23:45"), 83.75)
self.assertIsNone(runtime_minutes(""))
self.assertIsNone(runtime_minutes("収録時間 41:50"))
class Fc2ShopPageTests(unittest.TestCase):
def test_the_shop_page_gives_every_field_the_review_card_shows(self):
found = article(shop_page(), "FC2-PPV-4364209")
self.assertEqual(found["id"], "FC2-PPV-4364209")
self.assertEqual(found["content_id"], "4364209")
self.assertEqual(found["source_url"], "https://adult.contents.fc2.com/article/4364209/")
self.assertEqual(found["title"], TITLE)
self.assertEqual(found["release_date"], "2024-03-31")
self.assertEqual(found["runtime"], 41.83)
self.assertEqual(found["maker"], STUDIO)
self.assertEqual(found["label"], "大人仮面Z")
self.assertEqual(found["seller_url"], "https://adult.contents.fc2.com/users/otonakamenz/")
self.assertEqual(found["genres"], ["おっぱい", "お尻", "素人"])
self.assertEqual(found["cover_url"], COVER)
self.assertEqual(found["cover_urls"], [COVER])
def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self):
self.assertEqual(article(shop_page(), "FC2-PPV-4364209"), {
"id": "FC2-PPV-4364209", "content_id": "4364209",
"source_url": "https://adult.contents.fc2.com/article/4364209/", "title": TITLE,
"description": "本編と別アングルの2本立て。", "actresses": [], "maker": "FC2-PPV",
"label": "大人仮面Z", "seller_url": "https://adult.contents.fc2.com/users/otonakamenz/",
"series": "", "director": "", "release_date": "2024-03-31", "runtime": 41.83,
"genres": ["おっぱい", "お尻", "素人"], "cover_urls": [COVER], "cover_url": COVER})
def test_the_shop_page_names_no_performers(self):
# 标题里那个 `みお(19)` 是卖家自己写的宣传语,没有第二处可以印证;FC2 的演员
# 线索在 fc2cmadb 的评论区,走 `scripts/fetch_fc2_metadata.py`。
self.assertEqual(article(shop_page(), "FC2-PPV-4364209")["actresses"], [])
def test_another_products_page_is_not_this_ones_data(self):
# 站上的商品号会被复用给别的投稿,不核 sku 就把另一部片的资料写到这个番号头上。
with self.assertRaises(SourceFailure) as caught:
article(shop_page(video="4470851"), "FC2-PPV-4364209")
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.NOT_FOUND, "FC2 上没有这个商品"))
def test_a_delisted_product_reads_as_absent_not_as_a_broken_page(self):
with self.assertRaises(SourceFailure) as caught:
article(gone_page(), "FC2-PPV-4364209")
self.assertEqual((caught.exception.kind, str(caught.exception)), ("not_found", "FC2 上没有这个商品"))
def test_a_seller_without_a_name_keeps_the_page_that_identifies_it(self):
html = shop_page(seller="").replace(
'<a href="https://adult.contents.fc2.com/users/otonakamenz/"></a>', "")
found = article(html, "FC2-PPV-4364209")
self.assertEqual(found["label"], "https://adult.contents.fc2.com/users/otonakamenz/")
def test_the_shop_is_asked_once_on_its_own_host_with_the_fc2_page_limit(self):
transport = serve({"https://adult.contents.fc2.com/article/4364209/": shop_page()})
found = Fc2Source().records("FC2-PPV-4364209", session=Session(transport))
self.assertEqual([record.title for record in found], [TITLE])
self.assertEqual(transport.calls, [("https://adult.contents.fc2.com/article/4364209/",
"https://adult.contents.fc2.com/", 2 * 1024 * 1024, None)])
self.assertEqual(FC2.page_limit, 2 * 1024 * 1024)
class Fc2MirrorPageTests(unittest.TestCase):
def test_the_mirror_carries_what_the_shop_has_dropped(self):
found = mirror(mirror_page(), "FC2-PPV-3189161")
self.assertEqual(found["id"], "FC2-PPV-3189161")
self.assertEqual(found["source_url"], "https://fc2cmadb.com/articles/3189161")
self.assertEqual(found["title"], "【無】コスプレシリーズ")
self.assertEqual(found["release_date"], "2023-02-19")
self.assertEqual(found["runtime"], 46.1)
self.assertEqual(found["maker"], STUDIO)
self.assertEqual(found["label"], "梨奈の射精動画@個人撮影")
self.assertEqual(found["seller_url"], "https://adult.contents.fc2.com/users/rina_vlog/")
self.assertEqual(found["genres"], ["ハメ撮り", "フェラ"])
def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self):
self.assertEqual(mirror(mirror_page(), "FC2-PPV-3189161"), {
"id": "FC2-PPV-3189161", "content_id": "3189161",
"source_url": "https://fc2cmadb.com/articles/3189161", "title": "【無】コスプレシリーズ",
"description": "", "actresses": [], "maker": "FC2-PPV", "label": "梨奈の射精動画@個人撮影",
"seller_url": "https://adult.contents.fc2.com/users/rina_vlog/", "series": "", "director": "",
"release_date": "2023-02-19", "runtime": 46.1, "genres": ["ハメ撮り", "フェラ"],
"cover_urls": [COVER], "cover_url": COVER})
def test_the_named_women_come_from_the_second_ask_not_from_this_page(self):
# 女优是这一页的延迟 prop:首屏那份 HTML 里一个人也没有,点名要过才有。
self.assertEqual(mirror(mirror_page(), "FC2-PPV-3189161")["actresses"], [])
url = "https://fc2cmadb.com/articles/3189161"
transport = serve({url: lambda headers: (mirror_actresses("野々宮すず") if headers.get("X-Inertia")
else mirror_page())})
found = Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport))
self.assertEqual(found.payload()["actresses"], [{
"japanese_name": "野々宮すず", "external_id": "3667",
"alias_names": ["ののみやすず", "逢坂りの", "きたのあや"]}])
self.assertEqual(transport.calls, [(url, "https://fc2cmadb.com/", 2 * 1024 * 1024, None),
(url, url, 2 * 1024 * 1024, "actresses")])
def test_a_failed_second_ask_keeps_everything_else_the_mirror_gave(self):
# 那一跳撞上限流是常事,其余字段是站上最全的一份,不跟着丢。
url = "https://fc2cmadb.com/articles/3189161"
transport = serve({url: lambda headers: None if headers.get("X-Inertia") else mirror_page()})
found = Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport))
self.assertEqual((found.title, found.performers), ("【無】コスプレシリーズ", ()))
self.assertEqual(len(transport.calls), 2)
def test_the_second_ask_names_this_page_and_only_the_column_it_wants(self):
headers = partial_headers(mirror_page())
self.assertEqual(headers["X-Inertia-Version"], MIRROR_VERSION)
self.assertEqual(headers["X-Inertia-Partial-Component"], MIRROR_COMPONENT)
self.assertEqual(headers["X-Inertia-Partial-Data"], "actresses")
def test_a_page_that_is_not_a_product_page_is_never_asked_a_second_time(self):
# 站上没有的商品回的是错误页,它照样带着版本号,问下去只会白花一趟配额。
self.assertEqual(partial_headers(mirror_page(component="Error")), {})
self.assertEqual(partial_headers(mirror_page(version="")), {})
self.assertEqual(partial_headers("<div id='app'></div>"), {})
transport = serve({"https://fc2cmadb.com/articles/3189161": mirror_page(video="4364209")})
with self.assertRaises(SourceFailure):
Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport))
self.assertEqual(len(transport.calls), 1)
def test_the_stage_names_a_woman_has_worn_are_not_more_women(self):
# `alias_name` 那一串是同一个人的曾用名,一位女优挂着十几个。
found = parse_actresses(mirror_actresses("野々宮すず", "ゆうか"))
self.assertEqual([one["japanese_name"] for one in found], ["野々宮すず", "ゆうか"])
self.assertEqual([one["external_id"] for one in found], ["3667", "3668"])
# 放在 `alias_names` 而不是 `aliases`:后者会跟着资料页证据被直接登记成别名。
self.assertNotIn("aliases", found[0])
self.assertEqual(found[0]["alias_names"], ["ののみやすず", "逢坂りの", "きたのあや"])
self.assertEqual(parse_actresses(mirror_actresses()), [])
self.assertEqual(parse_actresses("<html>429</html>"), [])
def test_the_mirror_cover_points_at_the_file_itself(self):
# 镜像有时给的是缩放服务的地址。w276 只有 276 像素宽,连封面的最低宽度都过不了,
# 而它后半截就是原件地址(实测同一个文件 2350×2352)。
wrapped = "https://contents-thumbnail2.fc2.com/w276/" + COVER.removeprefix("https://")
found = mirror(mirror_page(image=wrapped), "FC2-PPV-3189161")
self.assertEqual(found["cover_url"], COVER)
self.assertEqual(found["cover_urls"], [COVER])
def test_the_mirrors_own_placeholder_is_not_a_cover(self):
# 站上没有商品图的条目挂的是镜像自己那张占位件,还是个站内相对地址。当封面交
# 下去,抓取那侧连主机都拼不出来,报回来的是一句「来源连接未取得」。
found = mirror(mirror_page(image="/storage/images/article/no-image.jpg"), "FC2-PPV-3189161")
self.assertEqual(found["cover_url"], "")
self.assertEqual(found["cover_urls"], [])
self.assertEqual(found["title"], "【無】コスプレシリーズ")
def test_another_products_mirror_page_is_not_this_ones_data(self):
for html in (mirror_page(video="4364209"), "<div id='app'></div>"):
with self.subTest(html=html[:40]), self.assertRaises(SourceFailure) as caught:
mirror(html, "FC2-PPV-3189161")
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.NOT_FOUND, "FC2CMADB 上没有这个商品"))
def test_the_two_pages_hand_back_the_same_shape(self):
# 两处的资料落进同一条候选流水线,字段少一个就是复核卡上少一格。
self.assertEqual(sorted(article(shop_page(), "FC2-PPV-4364209")),
sorted(mirror(mirror_page(), "FC2-PPV-3189161")))
self.assertEqual((FC2.name, FC2CMADB.name), ("fc2", "fc2cmadb"))
class JavArchiveTests(unittest.TestCase):
def test_the_search_page_gives_up_the_link_that_carries_this_shop_number(self):
self.assertEqual(links(search_page(), "FC2-PPV-4137487"), [ARCHIVE_LINK])
self.assertEqual(links(search_page().encode(), "fc2ppv4137487"), [ARCHIVE_LINK])
def test_a_longer_number_that_merely_starts_the_same_is_not_this_one(self):
# 站上 `4137487` 与 `41374870` 都有;按子串比会把后者的标题和封面安到前者头上。
self.assertEqual(links(search_page(video="41374870"), "FC2-PPV-4137487"), [])
self.assertEqual(links(search_page(), "FC2-PPV-9999999"), [])
self.assertEqual(links(search_page(), "ORETD-615"), [])
def test_one_search_page_holds_several_works_and_each_code_finds_its_own(self):
# 一页里既有别的作品,也有侧栏的热门与归档,挑的必须是这个商品号那一条。
self.assertTrue(links(search_page(), "FC2-PPV-1863914")[0].startswith("/735702-"))
self.assertTrue(links(search_page(), "FC2-PPV-4137487")[0].startswith("/926949-"))
def test_every_repost_of_the_same_work_is_handed_over(self):
# 不同转存者各发一次,图也各存各的:`1436028` 的 `/641159-` 那条是 404,
# `/800025-` 那条有 1280×720。先后没有质量含义,所以两条都要。
found = links(search_page(reposted=True), "FC2-PPV-4137487")
self.assertEqual([link.split("-")[0] for link in found], ["/926949", "/800025"])
def test_every_repost_becomes_its_own_record_and_a_dead_one_is_skipped(self):
search = "https://javarchive.com/search?q=FC2-PPV-4137487"
repost = "https://javarchive.com/800025-fc2ppv-4137487-離婚の後遺症で性欲が止まらない変態女-pn.html"
both = serve({search: search_page(reposted=True), "https://javarchive.com" + ARCHIVE_LINK: archive_page(),
repost: archive_page(large="{root}FC2PPV-{video}.jpg", small="")})
found = JavArchiveSource().records("FC2-PPV-4137487", session=Session(both))
self.assertEqual([record.cover_urls for record in found],
[(ARCHIVE_PICTURES + "4137487pl.jpg", ARCHIVE_PICTURES + "4137487ps.jpg"),
(ARCHIVE_PICTURES + "FC2PPV-4137487.jpg",)])
self.assertEqual([call[:3] for call in both.calls],
[(search, "https://javarchive.com/", 2 * 1024 * 1024),
("https://javarchive.com" + ARCHIVE_LINK, "https://javarchive.com/", 2 * 1024 * 1024),
(repost, "https://javarchive.com/", 2 * 1024 * 1024)])
# 第一条 404、第二条对不上番号时,只剩还在的那一条。
one = serve({search: search_page(reposted=True), repost: archive_page()})
self.assertEqual(len(JavArchiveSource().records("FC2-PPV-4137487", session=Session(one))), 1)
stray = serve({search: search_page(reposted=True), repost: archive_page(video="4364209")})
with self.assertRaises(SourceFailure) as caught:
JavArchiveSource().records("FC2-PPV-4137487", session=Session(stray))
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.NOT_FOUND, "JavArchive 上没有这个商品"))
def test_a_search_without_this_shop_number_is_absent_and_a_refusal_keeps_its_tier(self):
search = "https://javarchive.com/search?q=FC2-PPV-4137487"
with self.assertRaises(SourceFailure) as caught:
JavArchiveSource().records("FC2-PPV-4137487",
session=Session(serve({search: search_page(video="41374870")})))
self.assertEqual(str(caught.exception), "JavArchive 上没有这个商品")
def refused(request, timeout, limit):
return HttpResponse(403, {}, b"", request.url)
with self.assertRaises(SourceFailure) as caught:
JavArchiveSource().records("FC2-PPV-4137487", session=Session(refused))
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.AUTH_REQUIRED, "HTTP 403"))
def test_the_archive_page_gives_the_title_without_the_code_in_front_of_it(self):
found = archive(archive_page(), "FC2-PPV-4137487")
self.assertEqual(found["title"], "Gカップ・脅威 マジ凄いです!脅威のパイパーGカップグラマラス!")
self.assertEqual((found["id"], found["content_id"], found["maker"]),
("FC2-PPV-4137487", "4137487", STUDIO))
self.assertEqual(found["source_url"], "https://javarchive.com" + ARCHIVE_LINK)
self.assertEqual(archive(archive_page(title="FC2PPV 4137487 素顔"), "FC2-PPV-4137487")["title"],
"素顔")
def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self):
self.assertEqual(archive(archive_page(), "FC2-PPV-4137487"), {
"id": "FC2-PPV-4137487", "content_id": "4137487", "source_url": "https://javarchive.com" + ARCHIVE_LINK,
"title": ARCHIVE_TITLE, "description": "", "actresses": [], "maker": "FC2-PPV", "label": "",
"seller_url": "", "series": "", "director": "", "release_date": "2023-11-21", "runtime": 50.05,
"genres": ["ハメ撮り", "素人", "爆乳"],
"cover_urls": [ARCHIVE_PICTURES + "4137487pl.jpg", ARCHIVE_PICTURES + "4137487ps.jpg"],
"cover_url": ARCHIVE_PICTURES + "4137487pl.jpg"})
def test_the_cover_is_read_from_its_slot_whatever_the_transferrer_named_the_file(self):
"""封面认位置不认文件名:站上三种命名都有,按名字认的话两种一张都取不到。"""
found = archive(archive_page(), "FC2-PPV-4137487")
self.assertEqual(found["cover_url"], ARCHIVE_PICTURES + "4137487pl.jpg")
self.assertEqual(found["cover_urls"], [ARCHIVE_PICTURES + "4137487pl.jpg",
ARCHIVE_PICTURES + "4137487ps.jpg"])
# 转存者自己起的名字:`FC2PPV-4030617.jpg`、`FC2PPV835964-2.jpg`,都不带 `pl`/`ps`。
named = archive(archive_page(large="{root}FC2PPV-{video}.jpg",
small="{root}FC2PPV{video}-2.jpg"), "FC2-PPV-4137487")
self.assertEqual(named["cover_urls"], [ARCHIVE_PICTURES + "FC2PPV-4137487.jpg",
ARCHIVE_PICTURES + "FC2PPV4137487-2.jpg"])
# 只有 schema.org 那个图位时它就是封面(实测 `835964` 的页面就少了前一个)。
self.assertEqual(archive(archive_page(large=""), "FC2-PPV-4137487")["cover_url"],
ARCHIVE_PICTURES + "4137487ps.jpg")
self.assertEqual(archive(archive_page(large="", small=""), "FC2-PPV-4137487")["cover_urls"], [])
def test_the_stitched_preview_is_never_a_cover(self):
# `_s.jpg` 是把多帧拼成的长条(实测 1024×2000),装上去就是一格拉长的马赛克。
found = archive(archive_page(large="", small=""), "FC2-PPV-4137487")
self.assertEqual(found["cover_urls"], [])
self.assertNotIn("_s.jpg", str(archive(archive_page(), "FC2-PPV-4137487")["cover_urls"]))
def test_the_body_block_gives_up_the_tags_the_date_and_the_runtime(self):
"""转存者填了就取。2026-09-22 实测 4 部里只有 `4030617` 这块是齐的。"""
found = archive(archive_page(), "FC2-PPV-4137487")
self.assertEqual(found["genres"], ["ハメ撮り", "素人", "爆乳"])
self.assertEqual(found["release_date"], "2023-11-21")
self.assertEqual(found["runtime"], 50.05)
def test_an_empty_block_stays_empty_instead_of_taking_the_truncated_meta_line(self):
"""`<head>` 那条 description 里有同一段话的截断版,取回来就是半截标签加一串属性。"""
bare = archive(archive_page(tags="", release="", runtime=""), "FC2-PPV-4137487")
self.assertEqual((bare["genres"], bare["release_date"], bare["runtime"]), ([], "", None))
# 站上没填标签时那一行写成 `--`,当成一个标签就入了库。
self.assertEqual(archive(archive_page(tags="--"), "FC2-PPV-4137487")["genres"], [])
def test_another_products_archive_page_is_not_this_ones_data(self):
for html, code, wording in ((archive_page(video="4364209"), "FC2-PPV-4137487", "JavArchive 上没有这个商品"),
("<div class='news'></div>", "FC2-PPV-4137487", "JavArchive 上没有这个商品"),
(archive_page(), "ORETD-615", UNRECOGNISED)):
with self.subTest(code=code, html=html[:40]), self.assertRaises(SourceFailure) as caught:
archive(html, code)
self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, wording))
def test_the_third_page_hands_back_the_same_shape_as_the_first_two(self):
self.assertEqual(sorted(archive(archive_page(), "FC2-PPV-4137487")),
sorted(mirror(mirror_page(), "FC2-PPV-3189161")))
self.assertEqual(JAVARCHIVE.name, "javarchive")
self.assertEqual(JavArchiveSource().search_url("FC2-PPV-4137487"),
"https://javarchive.com/search?q=FC2-PPV-4137487")
self.assertEqual(JavArchiveSource().search_url("ORETD-615"), "")
DB_COVER = "https://d39jz7pbpqkw9s.cloudfront.net/thumbnails/48/4898837.webp"
def db_actress(at: int) -> str:
"""站内女优页的编号:标准写法的 uuid,实测 `5fb206c2-f8a0-4447-8e6e-c33869f54ea3`。"""
return f"5fb206c2-f8a0-4447-8e6e-{at:012d}"
def db_page(video="4898837", title="ふたりの巨乳美少女と3P。", seller="ぷにぷに製作所", slug="punipuni",
sold="2026年5月10日", runtime="55:00", performers=("川北すずね",), leaked="流出なし",
tags=("巨乳", "3P")):
"""fc2ppv-db.com 的作品页:Next.js 服务端渲染,「動画詳細情報」那块是嵌套的 div 与 p。
PR 位与関連動画也链到女优页,放进来看解析只认出演女優那一块。
"""
cast = ("".join(f'<a href="/ja/actresses/{db_actress(at)}"><span><img alt="{name}" src="/a.webp"></span>'
f"<span>{name}</span></a>" for at, name in enumerate(performers))
if performers else "<p>情報がありません</p>")
marks = "".join(f'<a href="/ja/videos?tags={tag}">{tag}</a>' for tag in tags)
return (
'<html><head><meta name="description" content="'
f'FC2-PPV-{video} {title} - 販売者: {seller} / 公開日: 2026年5月9日 / 再生時間: {runtime}">'
f'<meta property="og:image" content="{DB_COVER}"><meta property="og:image:width" content="800"></head>'
'<body><nav><a href="/ja/actresses/pr-uuid">今日の女優</a></nav><main>'
f"<h1>FC2-PPV-{video} {title}</h1>"
f'<img src="{DB_COVER}" alt="">'
"<section><h2>動画詳細情報</h2>"
f"<div><div><p>動画ID</p></div><div><p>{video}</p></div></div>"
f"<div><p>販売日</p><p>{sold}</p></div>"
f"<div><span>{leaked}</span><span>モザイクあり</span></div>"
f"<div><div><p>出演女優</p></div><div>{cast}</div></div>"
f'<div><a href="/ja/sellers/{slug}"><span><img alt="{seller}" src="/s.webp"></span>'
f"<span><span>販売者</span><span>{seller}</span></span></a></div>"
f"<div><div><span>タグ</span></div><div>{marks}</div></div>"
"</section></main>"
'<aside><h2>関連動画</h2><a href="/ja/actresses/other-uuid"><span>別の人</span></a></aside>'
"</body></html>"
)
def db_missing_page():
"""站上没有的商品回的也是 200,正文是它自己的 404 页。"""
return "<html><head><title>404</title></head><body><main><h1>404</h1><p>ページが見つかりません</p></main></body></html>"
def database(html, code):
return Fc2ppvdbSource().parse(page(html, "https://fc2ppv-db.com/ja/videos/4898837"), code).payload()
class Fc2ppvdbTests(unittest.TestCase):
def test_the_database_page_gives_the_women_the_seller_the_date_and_the_leak_mark(self):
found = database(db_page(), "FC2-PPV-4898837")
self.assertEqual(found["title"], "ふたりの巨乳美少女と3P。")
self.assertEqual(found["actresses"], [{"japanese_name": "川北すずね", "external_id": db_actress(0)}])
self.assertEqual((found["label"], found["seller_url"]),
("ぷにぷに製作所", "https://adult.contents.fc2.com/users/punipuni/"))
self.assertEqual((found["release_date"], found["runtime"]), ("2026-05-10", 55.0))
self.assertEqual(found["genres"], ["巨乳", "3P"])
self.assertEqual(found["leaked"], False)
self.assertEqual(database(db_page(leaked="流出あり"), "FC2-PPV-4898837")["leaked"], True)
self.assertEqual((found["id"], found["content_id"], found["maker"], found["source_url"]),
("FC2-PPV-4898837", "4898837", STUDIO, "https://fc2ppv-db.com/ja/videos/4898837"))
def test_the_thumbnail_is_not_handed_over_as_a_cover(self):
# 站上那张是 360×360 的 CloudFront 缩略图(`og:image:width` 写 800,实测 360),封面留给存储原件那几档。
found = database(db_page(), "FC2-PPV-4898837")
self.assertEqual((found["cover_urls"], found["cover_url"]), ([], ""))
def test_only_the_cast_block_names_the_women_of_this_film(self):
# PR 位与関連動画也链到女优页,那些不是这部片的人;站上没有女优时那一栏写「情報がありません」。
self.assertEqual(database(db_page(performers=("A", "B")), "FC2-PPV-4898837")["actresses"],
[{"japanese_name": "A", "external_id": db_actress(0)},
{"japanese_name": "B", "external_id": db_actress(1)}])
self.assertEqual(database(db_page(performers=()), "FC2-PPV-4898837")["actresses"], [])
def test_each_woman_carries_her_actress_page_id_once(self):
# 同一位在栏里列两次只留一个;链接不是站内女优页的 uuid 时只交名字,不拿半截地址当编号。
doubled = database(db_page(performers=("A", "A")), "FC2-PPV-4898837")["actresses"]
self.assertEqual(doubled, [{"japanese_name": "A", "external_id": db_actress(0)}])
odd = db_page(performers=("A",)).replace(f"/ja/actresses/{db_actress(0)}", "/ja/actresses/a-page")
self.assertEqual(database(odd, "FC2-PPV-4898837")["actresses"], [{"japanese_name": "A"}])
def test_the_sale_date_falls_back_to_the_meta_line_and_japanese_dates_read_as_iso(self):
self.assertEqual(japanese_date("2026年9月21日"), "2026-09-21")
self.assertEqual(japanese_date("いつか"), "")
missing = db_page().replace("<div><p>販売日</p><p>2026年5月10日</p></div>", "")
self.assertEqual(database(missing, "FC2-PPV-4898837")["release_date"], "2026-05-09")
def test_a_missing_product_and_a_challenge_page_each_keep_their_tier(self):
for html, code, reason, wording in (
(db_missing_page(), "FC2-PPV-99999999", FailureReason.NOT_FOUND, "FC2PPV-DB 上没有这个商品"),
(db_page(video="48988370"), "FC2-PPV-4898837", FailureReason.NOT_FOUND, "FC2PPV-DB 上没有这个商品"),
(db_page(), "ORETD-615", FailureReason.NOT_FOUND, UNRECOGNISED),
(CHALLENGE, "FC2-PPV-4898837", FailureReason.CLOUDFLARE_CHALLENGE,
"FC2PPV-DB 要求 Cloudflare 验证,请在采集设置里更新 Cookie")):
with self.subTest(code=code, html=html[:40]), self.assertRaises(SourceFailure) as caught:
database(html, code)
self.assertEqual((caught.exception.reason, str(caught.exception)), (reason, wording))
gate = page("<html><head><title>年齢確認 | FC2PPV Database</title></head><body><h1>年齢確認</h1></body></html>",
"https://fc2ppv-db.com/ja/age-verify?returnTo=%2Fja%2Fvideos%2F4898837")
with self.assertRaises(SourceFailure) as caught:
Fc2ppvdbSource().parse(gate, "FC2-PPV-4898837")
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.AUTH_REQUIRED, "FC2PPV-DB 送到了年齢確認页,浏览器没有点过去"),
"年龄门是会话状态不是这部片没有,不落 not_found")
def test_the_database_is_asked_once_on_its_own_host_and_hands_back_the_same_shape(self):
url = "https://fc2ppv-db.com/ja/videos/4898837"
pages = serve({url: db_page()})
found = Fc2ppvdbSource().query("FC2-PPV-4898837", session=Session(pages))
self.assertEqual(found.title, "ふたりの巨乳美少女と3P。")
self.assertEqual([call[:3] for call in pages.calls], [(url, "https://fc2ppv-db.com/", 2 * 1024 * 1024)])
self.assertEqual(FC2PPVDB.name, "fc2ppvdb")
self.assertEqual(sorted(set(database(db_page(), "FC2-PPV-4898837")) - {"leaked"}),
sorted(mirror(mirror_page(), "FC2-PPV-3189161")))
self.assertEqual(Fc2ppvdbSource().video_url("ORETD-615"), "")
#: 假传输按解码后的地址找页,夹具地址就写原字。
TEN_ORIGINAL = "https://javten.com/video/1234567/id4898837/ふたり"
TEN_STORAGE = "https://storage200000.contents.fc2.com/file/393/39269295/1778420440.09.png"
TEN_GALLERY = "//contents-thumbnail2.fc2.com/w1280/storage200000.contents.fc2.com/file/393/39269295/1778420440.53.png"
def ten_page(video="4898837", title="ふたりの巨乳美少女と3P。", seller="ぷにぷに製作所", runtime="55:00",
published="2026-05-10T22:02:04+07:00", tags=("巨乳", "3P"), url=TEN_ORIGINAL, gallery=TEN_GALLERY):
"""javten.com 的作品页:番号在 `h1.fc2-id`,卖家与时长只在 description 里。"""
marks = "".join(f'<a class="badge badge-primary" href="https://javten.com/tag/{at}/{tag}/newest">{tag}</a>'
for at, tag in enumerate(tags))
picture = (f'<a data-fancybox="gallery" href="{gallery}"><img data-src="{gallery.replace("w1280", "w500")}"></a>'
if gallery else "")
return (
f'<html><head><meta property="og:title" content="[FC2-PPV-{video}]{title}">'
f'<meta name="description" content="[FC2-PPV-{video}] | {title} | By {seller} | {runtime} | Free Sample Video">'
f'<meta property="og:url" content="{url}"><link rel="canonical" href="{url}">'
f'<meta property="og:image" content="{TEN_STORAGE}">'
f'<meta property="videos:published_time" content="{published}"></head>'
f'<body><h1 class="card-title fc2-id">FC2-PPV-{video}</h1><h2 class="card-title">{title}</h2>'
f"{picture}{marks}"
f'<a href="https://javten.com/seller/99/{seller}">この売り手からのすべてのビデオ</a>'
'<a href="https://javten.com/tw/video/1234567/id4898837/x">繁體</a>'
"</body></html>"
)
def ten_results(*videos, lang=""):
"""搜索结果页:同一部片的日文原页与几个译文版都列着,还有标题里带数字的别的片。"""
rows = "".join(f'<a href="https://javten.com/{lang}video/{1000 + at}/id{video}/title-{video}">FC2-PPV-{video}</a>'
f'<a href="https://javten.com/en/video/{1000 + at}/id{video}/title-{video}">EN</a>'
for at, video in enumerate(videos))
return f"<html><body>{rows}<a href='https://javten.com/video/7/id7777777/about-4898837'>别的片</a></body></html>"
def ten(html, code):
return JavtenSource().parse(page(html, TEN_ORIGINAL), code).payload()
class JavtenTests(unittest.TestCase):
def test_the_work_page_gives_the_japanese_title_the_seller_the_tags_and_the_storage_originals(self):
found = ten(ten_page(), "FC2-PPV-4898837")
self.assertEqual(found["title"], "ふたりの巨乳美少女と3P。")
self.assertEqual((found["label"], found["seller_url"]), ("ぷにぷに製作所", ""))
self.assertEqual((found["release_date"], found["runtime"]), ("2026-05-10", 55.0))
self.assertEqual(found["genres"], ["巨乳", "3P"])
self.assertEqual(found["cover_urls"], [
TEN_STORAGE, "https://storage200000.contents.fc2.com/file/393/39269295/1778420440.53.png"])
self.assertEqual((found["id"], found["content_id"], found["maker"], found["source_url"], found["actresses"]),
("FC2-PPV-4898837", "4898837", STUDIO, TEN_ORIGINAL, []))
self.assertEqual(ten(ten_page(gallery=""), "FC2-PPV-4898837")["cover_urls"], [TEN_STORAGE])
def test_the_search_page_gives_up_only_this_shop_numbers_japanese_page(self):
found = javten_module.links(ten_results("4898837", "1111111"), "FC2-PPV-4898837")
self.assertEqual(found, ["https://javten.com/video/1000/id4898837/title-4898837"])
# 标题里带着这个号的别的片、以及 `id48988370` 都不是它。
self.assertEqual(javten_module.links(ten_results("48988370"), "FC2-PPV-4898837"), [])
self.assertEqual(javten_module.links(ten_results("4898837"), "ORETD-615"), [])
def test_a_single_hit_lands_on_the_work_page_and_a_translated_landing_fetches_the_original(self):
search = "https://javten.com/search?kw=4898837"
direct = serve({search: ten_page()})
found = JavtenSource().query("FC2-PPV-4898837", session=Session(direct))
self.assertEqual((found.title, found.source_url), ("ふたりの巨乳美少女と3P。", TEN_ORIGINAL))
self.assertEqual([call[0] for call in direct.calls], [search])
translated = "https://javten.com/tw/video/1234567/id4898837/ふたり"
via_tw = serve({search: ten_page(url=translated), TEN_ORIGINAL: ten_page()})
JavtenSource().query("FC2-PPV-4898837", session=Session(via_tw))
self.assertEqual([call[:2] for call in via_tw.calls], [(search, "https://javten.com/"), (TEN_ORIGINAL, search)])
listed = serve({search: ten_results("4898837"), "https://javten.com/video/1000/id4898837/title-4898837": ten_page()})
JavtenSource().query("FC2-PPV-4898837", session=Session(listed))
self.assertEqual(len(listed.calls), 2)
def test_a_translated_page_is_never_read_as_the_title(self):
# 站上的中文是机器翻译,只收日文原页。
with self.assertRaises(SourceFailure) as caught:
ten(ten_page(url="https://javten.com/tw/video/1234567/id4898837/x", title="兩個巨乳美少女的3P"), "FC2-PPV-4898837")
self.assertEqual((caught.exception.reason, str(caught.exception)),
(FailureReason.PARSE_ERROR, "JAVten 回的是译文页,只收日文原页"))
def test_a_missing_product_and_a_challenge_page_each_keep_their_tier(self):
search = "https://javten.com/search?kw=4898837"
with self.assertRaises(SourceFailure) as caught:
JavtenSource().query("FC2-PPV-4898837", session=Session(serve({search: ten_results("1111111")})))
self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "JAVten 上没有这个商品"))
with self.assertRaises(SourceFailure) as caught:
JavtenSource().query("FC2-PPV-4898837", session=Session(serve({search: CHALLENGE})))
self.assertEqual((caught.exception.reason, caught.exception.status_code),
(FailureReason.CLOUDFLARE_CHALLENGE, 403))
for html, code, wording in ((ten_page(video="48988370"), "FC2-PPV-4898837", "JAVten 上没有这个商品"),
(ten_page(), "ORETD-615", UNRECOGNISED)):
with self.subTest(code=code), self.assertRaises(SourceFailure) as caught:
ten(html, code)
self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, wording))
def test_the_fifth_page_hands_back_the_same_shape_as_the_others(self):
self.assertEqual(sorted(ten(ten_page(), "FC2-PPV-4898837")), sorted(mirror(mirror_page(), "FC2-PPV-3189161")))
self.assertEqual(JAVTEN.name, "javten")
self.assertEqual(JavtenSource().search_url("FC2-PPV-4898837"), "https://javten.com/search?kw=4898837")
self.assertEqual(JavtenSource().search_url("ORETD-615"), "")
if __name__ == "__main__":
unittest.main()